Add Password Complexity Configuration

A potential solution to #1963.

This commit does the following:

1. Moves the values for minimum and maximum password length into the
ClientAPI configuration struct.
2. Introduces a new struct representing the password complexity
requirements defined in dendrite-config.yml, with four options. Defaults
are compatible with what users probably expect out of synapse.
  * Minimum length, default of 8
  * Maximum length, default of 512
  * Minimum number of symbols, default of 0
  * Requiring mixed case toggle, default of false
3. Adds tests for the logic of validating passwords.

Signed-off-by: Devon Mizelle <dev@devon.so>
This commit is contained in:
Devon Mizelle 2021-08-13 19:13:05 -04:00
parent 125ea75b24
commit c2a15d2119
8 changed files with 196 additions and 12 deletions

View file

@ -157,6 +157,15 @@ client_api:
threshold: 5 threshold: 5
cooloff_ms: 500 cooloff_ms: 500
# Settings for requiring complexity out of passwords.
password_requirements:
min_password_length: 8 # synapse default
max_password_length: 512 # synapse default
# minimum number of symbols (non a-z, A-Z to have)
min_number_symbols: 0
# should passwords have uppercase and lowercase characters?
require_mixed_case: false
# Configuration for the EDU server. # Configuration for the EDU server.
edu_server: edu_server:
internal_api: internal_api:

View file

@ -78,7 +78,7 @@ func Password(
AddCompletedSessionStage(sessionID, authtypes.LoginTypePassword) AddCompletedSessionStage(sessionID, authtypes.LoginTypePassword)
// Check the new password strength. // Check the new password strength.
if resErr = validatePassword(r.NewPassword); resErr != nil { if resErr = validatePassword(r.NewPassword, cfg.PasswordRequirements); resErr != nil {
return *resErr return *resErr
} }

View file

@ -57,8 +57,6 @@ var (
) )
const ( const (
minPasswordLength = 8 // http://matrix.org/docs/spec/client_server/r0.2.0.html#password-based
maxPasswordLength = 512 // https://github.com/matrix-org/synapse/blob/v0.20.0/synapse/rest/client/v2_alpha/register.py#L161
maxUsernameLength = 254 // http://matrix.org/speculator/spec/HEAD/intro.html#user-identifiers TODO account for domain maxUsernameLength = 254 // http://matrix.org/speculator/spec/HEAD/intro.html#user-identifiers TODO account for domain
sessionIDLength = 24 sessionIDLength = 24
) )
@ -111,6 +109,10 @@ var (
// sessions stores the completed flow stages for all sessions. Referenced using their sessionID. // sessions stores the completed flow stages for all sessions. Referenced using their sessionID.
sessions = newSessionsDict() sessions = newSessionsDict()
validUsernameRegex = regexp.MustCompile(`^[0-9a-z_\-=./]+$`) validUsernameRegex = regexp.MustCompile(`^[0-9a-z_\-=./]+$`)
passwordSymbols = regexp.MustCompile(`[^0-9a-zA-Z]`)
passwordUppercase = regexp.MustCompile(`[A-Z]`)
passwordLowercase = regexp.MustCompile(`[a-z]`)
) )
// registerRequest represents the submitted registration request. // registerRequest represents the submitted registration request.
@ -225,17 +227,38 @@ func validateApplicationServiceUsername(username string) *util.JSONResponse {
} }
// validatePassword returns an error response if the password is invalid // validatePassword returns an error response if the password is invalid
func validatePassword(password string) *util.JSONResponse { func validatePassword(password string, cfg config.PasswordRequirements) *util.JSONResponse {
// https://github.com/matrix-org/synapse/blob/v0.20.0/synapse/rest/client/v2_alpha/register.py#L161 // https://github.com/matrix-org/synapse/blob/v0.20.0/synapse/rest/client/v2_alpha/register.py#L161
if len(password) > maxPasswordLength { if len(password) > cfg.MaxPasswordLength {
return &util.JSONResponse{ return &util.JSONResponse{
Code: http.StatusBadRequest, Code: http.StatusBadRequest,
JSON: jsonerror.BadJSON(fmt.Sprintf("'password' >%d characters", maxPasswordLength)), JSON: jsonerror.WeakPassword(fmt.Sprintf("'password' >%d characters", cfg.MaxPasswordLength)),
} }
} else if len(password) > 0 && len(password) < minPasswordLength { } else if len(password) > 0 && len(password) < cfg.MinPasswordLength {
return &util.JSONResponse{ return &util.JSONResponse{
Code: http.StatusBadRequest, Code: http.StatusBadRequest,
JSON: jsonerror.WeakPassword(fmt.Sprintf("password too weak: min %d chars", minPasswordLength)), JSON: jsonerror.WeakPassword(fmt.Sprintf("password too weak: min %d chars", cfg.MinPasswordLength)),
}
}
if cfg.MinNumberSymbols > 0 {
matches := passwordSymbols.FindAllStringIndex(password, -1)
if len(matches) < cfg.MinNumberSymbols {
return &util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.WeakPassword(fmt.Sprintf("password too weak: minimum %d symbols", cfg.MinNumberSymbols)),
}
}
}
if cfg.RequireMixedCase {
lowercase := passwordLowercase.FindAllStringIndex(password, -1)
uppercase := passwordUppercase.FindAllStringIndex(password, -1)
if len(lowercase) == 0 || len(uppercase) == 0 {
return &util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.WeakPassword("password must have uppercase and lowercase letters"),
}
} }
} }
return nil return nil
@ -511,7 +534,7 @@ func Register(
return *resErr return *resErr
} }
} }
if resErr = validatePassword(r.Password); resErr != nil { if resErr = validatePassword(r.Password, cfg.PasswordRequirements); resErr != nil {
return *resErr return *resErr
} }
@ -935,7 +958,7 @@ func RegisterAvailable(
} }
} }
func handleSharedSecretRegistration(userAPI userapi.UserInternalAPI, sr *SharedSecretRegistration, req *http.Request) util.JSONResponse { func handleSharedSecretRegistration(userAPI userapi.UserInternalAPI, sr *SharedSecretRegistration, passwordRequirements config.PasswordRequirements, req *http.Request) util.JSONResponse {
ssrr, err := NewSharedSecretRegistrationRequest(req.Body) ssrr, err := NewSharedSecretRegistrationRequest(req.Body)
if err != nil { if err != nil {
return util.JSONResponse{ return util.JSONResponse{
@ -959,7 +982,7 @@ func handleSharedSecretRegistration(userAPI userapi.UserInternalAPI, sr *SharedS
if resErr := validateUsername(ssrr.User); resErr != nil { if resErr := validateUsername(ssrr.User); resErr != nil {
return *resErr return *resErr
} }
if resErr := validatePassword(ssrr.Password); resErr != nil { if resErr := validatePassword(ssrr.Password, passwordRequirements); resErr != nil {
return *resErr return *resErr
} }
deviceID := "shared_secret_registration" deviceID := "shared_secret_registration"

View file

@ -15,11 +15,15 @@
package routing package routing
import ( import (
"fmt"
"net/http"
"regexp" "regexp"
"testing" "testing"
"github.com/matrix-org/dendrite/clientapi/auth/authtypes" "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
"github.com/matrix-org/dendrite/clientapi/jsonerror"
"github.com/matrix-org/dendrite/setup/config" "github.com/matrix-org/dendrite/setup/config"
"github.com/matrix-org/util"
) )
var ( var (
@ -208,3 +212,108 @@ func TestValidationOfApplicationServices(t *testing.T) {
t.Errorf("user_id should not have been valid: @_something_else:localhost") t.Errorf("user_id should not have been valid: @_something_else:localhost")
} }
} }
func TestValidatePassword(t *testing.T) {
t.Parallel()
defaults := &config.PasswordRequirements{}
defaults.Defaults()
custom := &config.PasswordRequirements{
MinPasswordLength: 16,
MaxPasswordLength: 32,
RequireMixedCase: true,
MinNumberSymbols: 5,
}
var testCases = []struct {
name string
config config.PasswordRequirements
password string
expected *util.JSONResponse
}{
{
"default reject too short",
*defaults,
"foobar",
&util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.WeakPassword(fmt.Sprintf("password too weak: min %d chars", defaults.MinPasswordLength)),
}}, {"default reject too long",
*defaults,
// len 600
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
&util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.WeakPassword(fmt.Sprintf("'password' >%d characters", defaults.MaxPasswordLength)),
},
},
{
"set min too short",
*custom,
"abcd",
&util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.WeakPassword(fmt.Sprintf("password too weak: min %d chars", custom.MinPasswordLength)),
},
},
{
"set max too long",
*custom,
// len 33
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
&util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.WeakPassword(fmt.Sprintf("'password' >%d characters", custom.MaxPasswordLength)),
},
},
{
"set symbols not enough",
*custom,
"thi$i$apasswordshouldbelong",
&util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.WeakPassword(fmt.Sprintf("password too weak: minimum %d symbols", custom.MinNumberSymbols)),
},
},
{
"require mixed case but none",
*custom,
"haha_all_lowercase_cant_catch_me",
&util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.WeakPassword("password must have uppercase and lowercase letters"),
},
},
{
"custom settings but valid",
*custom,
"$0me_$up3r_$trong_P@ass",
nil,
},
}
for _, test := range testCases {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
response := validatePassword(test.password, test.config)
if test.expected == nil && response != nil {
t.Errorf("expected password to be validated. got error: %s", response.JSON.(*jsonerror.MatrixError).Err)
} else if test.expected != nil && response == nil {
t.Errorf("expected password to fail, but was validated")
} else if test.expected != nil && test.expected.Code != response.Code {
t.Errorf("expected error code %d, got %d", test.expected.Code, response.Code)
} else if test.expected != nil && response != nil {
matrixError := response.JSON.(*jsonerror.MatrixError)
expectedError := test.expected.JSON.(*jsonerror.MatrixError)
if expectedError.Err != matrixError.Err {
t.Errorf("expected error: %s, got error: %s", expectedError.Err, matrixError.Err)
}
} else if test.expected == nil && response == nil {
t.Log("password validation passed")
} else {
t.Errorf("uncaught test result. expected %v, got %v", test.expected, response)
}
})
}
}

View file

@ -107,7 +107,7 @@ func Setup(
} }
} }
if req.Method == http.MethodPost { if req.Method == http.MethodPost {
return handleSharedSecretRegistration(userAPI, sr, req) return handleSharedSecretRegistration(userAPI, sr, cfg.PasswordRequirements, req)
} }
return util.JSONResponse{ return util.JSONResponse{
Code: http.StatusMethodNotAllowed, Code: http.StatusMethodNotAllowed,

View file

@ -174,6 +174,15 @@ client_api:
threshold: 5 threshold: 5
cooloff_ms: 500 cooloff_ms: 500
# Settings for requiring complexity out of passwords.
password_requirements:
min_password_length: 8 # synapse default
max_password_length: 512 # synapse default
# minimum number of symbols (non a-z, A-Z to have)
min_number_symbols: 0
# should passwords have uppercase and lowercase characters?
require_mixed_case: false
# Configuration for the EDU server. # Configuration for the EDU server.
edu_server: edu_server:
internal_api: internal_api:

View file

@ -32,6 +32,9 @@ type ClientAPI struct {
// was successful // was successful
RecaptchaSiteVerifyAPI string `yaml:"recaptcha_siteverify_api"` RecaptchaSiteVerifyAPI string `yaml:"recaptcha_siteverify_api"`
// Used to enforce standards on password strengths
PasswordRequirements PasswordRequirements `yaml:"password_requirements"`
// TURN options // TURN options
TURN TURN `yaml:"turn"` TURN TURN `yaml:"turn"`
@ -53,6 +56,7 @@ func (c *ClientAPI) Defaults() {
c.RecaptchaSiteVerifyAPI = "" c.RecaptchaSiteVerifyAPI = ""
c.RegistrationDisabled = false c.RegistrationDisabled = false
c.RateLimiting.Defaults() c.RateLimiting.Defaults()
c.PasswordRequirements.Defaults()
} }
func (c *ClientAPI) Verify(configErrs *ConfigErrors, isMonolith bool) { func (c *ClientAPI) Verify(configErrs *ConfigErrors, isMonolith bool) {
@ -68,6 +72,7 @@ func (c *ClientAPI) Verify(configErrs *ConfigErrors, isMonolith bool) {
} }
c.TURN.Verify(configErrs) c.TURN.Verify(configErrs)
c.RateLimiting.Verify(configErrs) c.RateLimiting.Verify(configErrs)
c.PasswordRequirements.Verify(configErrs)
} }
type TURN struct { type TURN struct {
@ -123,3 +128,27 @@ func (r *RateLimiting) Defaults() {
r.Threshold = 5 r.Threshold = 5
r.CooloffMS = 500 r.CooloffMS = 500
} }
type PasswordRequirements struct {
// Minimum number of characters
MinPasswordLength int `yaml:"min_password_length"`
// Maximum number of characters
MaxPasswordLength int `yaml:"max_password_length"`
// Number of symbols required
MinNumberSymbols int `yaml:"minimum_number_of_symbols"`
// Should the password have uppercase and lowercase characters
RequireMixedCase bool `yaml:"require_mixed_case"`
}
func (p *PasswordRequirements) Verify(configErrs *ConfigErrors) {
checkPositive(configErrs, "client_api.password_requirements.min_password_length", int64(p.MinPasswordLength))
checkPositive(configErrs, "client_api.password_requirements.max_password_length", int64(p.MaxPasswordLength))
checkPositive(configErrs, "client_api.password_requirements.min_number_symbols", int64(p.MinNumberSymbols))
}
func (p *PasswordRequirements) Defaults() {
p.MinPasswordLength = 8 // http://matrix.org/docs/spec/client_server/r0.2.0.html#password-based
p.MaxPasswordLength = 512 // https://github.com/matrix-org/synapse/blob/v0.20.0/synapse/rest/client/v2_alpha/register.py#L161
p.MinNumberSymbols = 0
p.RequireMixedCase = false
}

View file

@ -86,6 +86,11 @@ client_api:
turn_shared_secret: "" turn_shared_secret: ""
turn_username: "" turn_username: ""
turn_password: "" turn_password: ""
password_requirements:
min_password_length: 6
max_password_length: 64
required_number_symbols: 2
require_mixed_case: true
current_state_server: current_state_server:
internal_api: internal_api:
listen: http://localhost:7782 listen: http://localhost:7782