mirror of
https://github.com/matrix-org/dendrite.git
synced 2025-12-29 01:33:10 -06:00
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:
parent
125ea75b24
commit
c2a15d2119
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue