Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions internal/api/admin.go
Original file line number Diff line number Diff line change
@@ -32,6 +32,7 @@ type AdminUserParams struct {
UserMetaData map[string]interface{} `json:"user_metadata"`
AppMetaData map[string]interface{} `json:"app_metadata"`
BanDuration string `json:"ban_duration"`
EnforcePasswordCheck bool `json:"enforce_password_check"`
}

type adminUserDeleteParams struct {
@@ -179,8 +180,10 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
if params.Password != nil {
password := *params.Password

if err := a.checkPasswordStrength(ctx, password); err != nil {
return err
if params.EnforcePasswordCheck {
if err := a.checkPasswordStrength(ctx, password); err != nil {
return err
}
}

if err := user.SetPassword(ctx, password, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil {
@@ -381,6 +384,14 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
params.Password = &password
}

if params.Password != nil {
if params.EnforcePasswordCheck {
if err := a.checkPasswordStrength(ctx, *params.Password); err != nil {
return err
}
}
}

var user *models.User
if params.PasswordHash != "" {
user, err = models.NewUserWithPasswordHash(params.Phone, params.Email, params.PasswordHash, aud, params.UserMetaData)
187 changes: 172 additions & 15 deletions internal/api/admin_test.go
Original file line number Diff line number Diff line change
@@ -238,31 +238,31 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
desc: "Only phone",
params: map[string]interface{}{
"phone": "123456789",
"password": "test1",
"password": "StrongPassword123!",
},
expected: map[string]interface{}{
"email": "",
"phone": "123456789",
"isAuthenticated": true,
"provider": "phone",
"providers": []string{"phone"},
"password": "test1",
"password": "StrongPassword123!",
},
},
{
desc: "With password",
params: map[string]interface{}{
"email": "test1@example.com",
"phone": "123456789",
"password": "test1",
"password": "StrongPassword123!",
},
expected: map[string]interface{}{
"email": "test1@example.com",
"phone": "123456789",
"isAuthenticated": true,
"provider": "email",
"providers": []string{"email", "phone"},
"password": "test1",
"password": "StrongPassword123!",
},
},
{
@@ -300,7 +300,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
params: map[string]interface{}{
"email": "test4@example.com",
"phone": "",
"password": "test1",
"password": "StrongPassword123!",
"ban_duration": "24h",
},
expected: map[string]interface{}{
@@ -309,7 +309,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
"isAuthenticated": true,
"provider": "email",
"providers": []string{"email"},
"password": "test1",
"password": "StrongPassword123!",
},
},
{
@@ -332,7 +332,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
params: map[string]interface{}{
"id": "fc56ab41-2010-4870-a9b9-767c1dc573fb",
"email": "test6@example.com",
"password": "test",
"password": "StrongPassword123!",
},
expected: map[string]interface{}{
"id": "fc56ab41-2010-4870-a9b9-767c1dc573fb",
@@ -341,7 +341,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
"isAuthenticated": true,
"provider": "email",
"providers": []string{"email"},
"password": "test",
"password": "StrongPassword123!",
},
},
}
@@ -505,6 +505,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdatePasswordFailed() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"password": "",
"enforce_password_check": true,
}))

// Setup request
@@ -711,7 +712,7 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
},
userData: map[string]interface{}{
"email": "test1@example.com",
"password": "test1",
"password": "StrongPassword123!",
},
expected: http.StatusOK,
},
@@ -727,7 +728,7 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
},
userData: map[string]interface{}{
"phone": "123456789",
"password": "test1",
"password": "StrongPassword123!",
},
expected: http.StatusOK,
},
@@ -739,7 +740,7 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
},
userData: map[string]interface{}{
"email": "test2@example.com",
"password": "test2",
"password": "StrongPassword123!",
},
expected: http.StatusOK,
},
@@ -860,43 +861,62 @@ func (ts *AdminTestSuite) TestAdminUserCreateValidationErrors() {
cases := []struct {
desc string
params map[string]interface{}
code int
}{
{
desc: "create user without email and phone",
params: map[string]interface{}{
"password": "test_password",
"password": "StrongPassword123!",
},
code: http.StatusBadRequest,
},
{
desc: "weak password that doesn't meet the minimum length",
params: map[string]interface{}{
"email": "test@example.com",
"password": "weak",
"enforce_password_check": true,
},
code: http.StatusUnprocessableEntity,
},
{
desc: "create user with password and password hash",
params: map[string]interface{}{
"email": "test@example.com",
"password": "test_password",
"password": "StrongPassword123!",
"password_hash": "$2y$10$Tk6yEdmTbb/eQ/haDMaCsuCsmtPVprjHMcij1RqiJdLGPDXnL3L1a",
},
code: http.StatusBadRequest,
},
{
desc: "invalid ban duration",
params: map[string]interface{}{
"email": "test@example.com",
"ban_duration": "never",
},
code: http.StatusBadRequest,
},
{
desc: "custom id is nil",
params: map[string]interface{}{
"id": "00000000-0000-0000-0000-000000000000",
"email": "test@example.com",
},
code: http.StatusBadRequest,
},
{
desc: "bad id format",
params: map[string]interface{}{
"id": "bad_uuid_format",
"email": "test@example.com",
},
code: http.StatusBadRequest,
},
}

originalMinLength := ts.Config.Password.MinLength
ts.Config.Password.MinLength = 8

for _, c := range cases {
ts.Run(c.desc, func() {
var buffer bytes.Buffer
@@ -905,12 +925,149 @@ func (ts *AdminTestSuite) TestAdminUserCreateValidationErrors() {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
require.Equal(ts.T(), http.StatusBadRequest, w.Code, w)

require.Equal(ts.T(), c.code, w.Code, "Expected status code %d but got %d for test case: %s", c.code, w.Code, c.desc)

data := map[string]interface{}{}
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
require.Equal(ts.T(), data["error_code"], apierrors.ErrorCodeValidationFailed)

if c.code == http.StatusBadRequest {
require.Equal(ts.T(), data["error_code"], apierrors.ErrorCodeValidationFailed)
} else if c.code == http.StatusUnprocessableEntity && c.desc == "weak password that doesn't meet the minimum length" {
require.Equal(ts.T(), "weak_password", data["error_code"])
}
})

}

ts.Config.Password.MinLength = originalMinLength
}

func (ts *AdminTestSuite) TestAdminUserCreateWithEnforcePasswordCheck() {
originalMinLength := ts.Config.Password.MinLength
ts.Config.Password.MinLength = 20

weakPassword := "short"

ts.Run("With enforce flag, should fail validation", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"email": "test@example.com",
"password": weakPassword,
"enforce_password_check": true,
}))

req := httptest.NewRequest(http.MethodPost, "/admin/users", &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)

require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code)
})

ts.Run("Without enforce flag, should succeed despite weak password", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"email": "test@example.com",
"password": weakPassword,
"enforce_password_check": false,
}))

req := httptest.NewRequest(http.MethodPost, "/admin/users", &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)

require.Equal(ts.T(), http.StatusOK, w.Code)

data := models.User{}
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
require.Equal(ts.T(), "test@example.com", data.GetEmail())

u, err := models.FindUserByID(ts.API.db, data.ID)
require.NoError(ts.T(), err)
isAuthenticated, _, err := u.Authenticate(context.Background(), ts.API.db, weakPassword,
ts.API.config.Security.DBEncryption.DecryptionKeys,
ts.API.config.Security.DBEncryption.Encrypt,
ts.API.config.Security.DBEncryption.EncryptionKeyID)
require.NoError(ts.T(), err)
require.True(ts.T(), isAuthenticated)
})

ts.Config.Password.MinLength = originalMinLength
}

func (ts *AdminTestSuite) TestAdminUserUpdateWithEnforcePasswordCheck() {
u, err := models.NewUser("", "test@example.com", "original", 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")

originalMinLength := ts.Config.Password.MinLength
ts.Config.Password.MinLength = 20 // Set a high minimum to ensure our test password is "weak"

weakPassword := "short"

ts.Run("Without enforce_password_check parameter (default), should accept weak password", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"password": weakPassword,
}))

req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/users/%s", u.ID), &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)

require.Equal(ts.T(), http.StatusOK, w.Code, "Expected weak password to be accepted when enforce_password_check is not provided")

updatedUser, err := models.FindUserByID(ts.API.db, u.ID)
require.NoError(ts.T(), err)
isAuthenticated, _, err := updatedUser.Authenticate(context.Background(), ts.API.db, weakPassword,
ts.API.config.Security.DBEncryption.DecryptionKeys,
ts.API.config.Security.DBEncryption.Encrypt,
ts.API.config.Security.DBEncryption.EncryptionKeyID)
require.NoError(ts.T(), err)
require.True(ts.T(), isAuthenticated, "Should be able to authenticate with the weak password")
})

ts.Run("With enforce_password_check=false, should accept weak password", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"password": weakPassword,
"enforce_password_check": false,
}))

req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/users/%s", u.ID), &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)

require.Equal(ts.T(), http.StatusOK, w.Code, "Expected weak password to be accepted when enforce_password_check=false")

updatedUser, err := models.FindUserByID(ts.API.db, u.ID)
require.NoError(ts.T(), err)
isAuthenticated, _, err := updatedUser.Authenticate(context.Background(), ts.API.db, weakPassword,
ts.API.config.Security.DBEncryption.DecryptionKeys,
ts.API.config.Security.DBEncryption.Encrypt,
ts.API.config.Security.DBEncryption.EncryptionKeyID)
require.NoError(ts.T(), err)
require.True(ts.T(), isAuthenticated, "Should be able to authenticate with the weak password")
})

ts.Run("With enforce_password_check=true, should reject weak password", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"password": weakPassword,
"enforce_password_check": true,
}))

req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/users/%s", u.ID), &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)

require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code, "Expected weak password to be rejected when enforce_password_check=true")
})

ts.Config.Password.MinLength = originalMinLength
}
3 changes: 2 additions & 1 deletion internal/api/mail.go
Original file line number Diff line number Diff line change
@@ -34,6 +34,7 @@ type GenerateLinkParams struct {
Password string `json:"password"`
Data map[string]interface{} `json:"data"`
RedirectTo string `json:"redirect_to"`
EnforcePasswordCheck bool `json:"enforce_password_check"`
}

type GenerateLinkResponse struct {
@@ -102,7 +103,7 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error {
Aud: aud,
}

if err := a.validateSignupParams(ctx, signupParams); err != nil {
if err := a.validateSignupParams(ctx, signupParams, params.EnforcePasswordCheck); err != nil {
return err
}

61 changes: 61 additions & 0 deletions internal/api/mail_test.go
Original file line number Diff line number Diff line change
@@ -255,3 +255,64 @@ func (ts *MailTestSuite) setURIAllowListMap(uris ...string) {
ts.Config.URIAllowListMap[uri] = g
}
}

func (ts *MailTestSuite) TestGenerateLinkWithEnforcePasswordCheck() {
claims := &AccessTokenClaims{
Role: "supabase_admin",
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(ts.Config.JWT.Secret))
require.NoError(ts.T(), err, "Error generating admin jwt")

ts.setURIAllowListMap("http://localhost:8000/**")

originalMinLength := ts.Config.Password.MinLength
ts.Config.Password.MinLength = 20

weakPassword := "short"

customDomainUrl, err := url.ParseRequestURI("https://example.gotrue.com")
require.NoError(ts.T(), err)

originalHosts := ts.API.config.Mailer.ExternalHosts
ts.API.config.Mailer.ExternalHosts = []string{
"example.gotrue.com",
}

ts.Run("Generate signup link without enforce should succeed", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(GenerateLinkParams{
Email: "non-existent-user@example.com",
Password: weakPassword,
Type: "signup",
EnforcePasswordCheck: false,
}))

req := httptest.NewRequest(http.MethodPost, customDomainUrl.String()+"/admin/generate_link", &buffer)
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)
// ...
})

ts.Run("Generate signup link with enforce should fail", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(GenerateLinkParams{
Email: "non-existent1-user@example.com",
Password: weakPassword,
Type: "signup",
EnforcePasswordCheck: true,
}))

req := httptest.NewRequest(http.MethodPost, customDomainUrl.String()+"/admin/generate_link", &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)

require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code)
})

ts.Config.Password.MinLength = originalMinLength
ts.API.config.Mailer.ExternalHosts = originalHosts
}
11 changes: 7 additions & 4 deletions internal/api/signup.go
Original file line number Diff line number Diff line change
@@ -29,16 +29,19 @@ type SignupParams struct {
CodeChallenge string `json:"code_challenge"`
}

func (a *API) validateSignupParams(ctx context.Context, p *SignupParams) error {
func (a *API) validateSignupParams(ctx context.Context, p *SignupParams, enforcePasswordCheck bool) error {
config := a.config

if p.Password == "" {
return badRequestError(apierrors.ErrorCodeValidationFailed, "Signup requires a valid password")
}

if err := a.checkPasswordStrength(ctx, p.Password); err != nil {
return err
if enforcePasswordCheck {
if err := a.checkPasswordStrength(ctx, p.Password); err != nil {
return err
}
}

if p.Email != "" && p.Phone != "" {
return badRequestError(apierrors.ErrorCodeValidationFailed, "Only an email address or phone number should be provided on signup.")
}
@@ -123,7 +126,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {

params.ConfigureDefaults()

if err := a.validateSignupParams(ctx, params); err != nil {
if err := a.validateSignupParams(ctx, params, false); err != nil {
return err
}

24 changes: 24 additions & 0 deletions internal/api/signup_test.go
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ package api

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
@@ -151,3 +152,26 @@ func (ts *SignupTestSuite) TestVerifySignup() {
require.NotEmpty(ts.T(), v.Get("expires_in"))
require.NotEmpty(ts.T(), v.Get("refresh_token"))
}

func (ts *SignupTestSuite) TestValidateSignupParamsWithEnforcePasswordCheck() {
originalMinLength := ts.Config.Password.MinLength
ts.Config.Password.MinLength = 20

weakPassword := "short"

params := &SignupParams{
Email: "non-existing@example.com",
Password: weakPassword,
Provider: "email",
Aud: ts.Config.JWT.Aud,
}

err := ts.API.validateSignupParams(context.Background(), params, false)
require.NoError(ts.T(), err, "Should bypass validation with enforcePasswordCheck=false")

err = ts.API.validateSignupParams(context.Background(), params, true)
require.Error(ts.T(), err, "Should fail validation with enforcePasswordCheck=true")
require.Contains(ts.T(), err.Error(), "should be at least")

ts.Config.Password.MinLength = originalMinLength
}
55 changes: 55 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
@@ -1222,6 +1222,8 @@ paths:
redirect_to:
type: string
format: uri
enforce_password_check:
type: boolean
responses:
200:
description: User profile and generated link information.
@@ -1401,6 +1403,57 @@ paths:
$ref: "#/components/responses/UnauthorizedResponse"
403:
$ref: "#/components/responses/ForbiddenResponse"
post:
summary: Create a new user .
tags:
- admin
security:
- APIKeyAuth: []
AdminAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
id:
type: string
format: uuid
email:
type: string
format: email
phone:
type: string
format: phone
password:
type: string
password_hash:
type: string
email_confirm:
type: boolean
phone_confirm:
type: boolean
user_metadata:
type: object
app_metadata:
type: object
ban_duration:
type: string
enforce_password_check:
type: boolean
responses:
200:
description: User was successfully created.
content:
application/json:
schema:
$ref: "#/components/schemas/UserSchema"
400:
$ref: "#/components/responses/BadRequestResponse"
401:
$ref: "#/components/responses/UnauthorizedResponse"
403:
$ref: "#/components/responses/ForbiddenResponse"

/admin/users/{userId}:
parameters:
@@ -1446,6 +1499,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/UserSchema"
enforce_password_check:
type: boolean
responses:
200:
description: User's account data was updated.