MF-1317 - Configurable regexp rule for password (#1355)

* read and validate regex envar

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* pass regexp to user/api

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* resolve conflicts

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* use exported regexp variable

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* move password validation from users package

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* remove dead code

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* add password change request

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* move regexp from api to users package

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* fix tests

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* remove commented code

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* add regexp as field in userService, remove it as user exported global var

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* add passwd validation in service

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* Add psswd validation for change password in service

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* add password validation in password reset

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* Remove password validation from user validation test

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* Replace email and passwords in test with constants

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* compile error not fail silently

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* fix tempate path

Signed-off-by: Ivan Milosevic <iva@blokovi.com>
This commit is contained in:
Ivan Milošević 2021-03-01 15:22:57 +01:00 committed by GitHub
parent e334569d81
commit 7bcaa323d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 69 additions and 35 deletions

1
.env
View File

@ -46,6 +46,7 @@ MF_USERS_DB=users
MF_USERS_ADMIN_EMAIL=admin@example.com
MF_USERS_ADMIN_PASSWORD=12345678
MF_USERS_RESET_PWD_TEMPLATE=users.tmpl
MF_USERS_PASS_REGEX=^.{8,}$
### Email utility
MF_EMAIL_HOST=smtp.mailtrap.io

View File

@ -12,6 +12,7 @@ import (
"net/http"
"os"
"os/signal"
"regexp"
"strconv"
"syscall"
"time"
@ -63,6 +64,7 @@ const (
defEmailTemplate = "email.tmpl"
defAdminEmail = ""
defAdminPassword = ""
defPassRegex = "^.{8,}$"
defAdminGroup = "mainflux"
defTokenResetEndpoint = "/reset-request" // URL where user lands after click on the reset link from email
@ -89,6 +91,7 @@ const (
envAdminEmail = "MF_USERS_ADMIN_EMAIL"
envAdminPassword = "MF_USERS_ADMIN_PASSWORD"
envPassRegex = "MF_USERS_PASS_REGEX"
envEmailHost = "MF_EMAIL_HOST"
envEmailPort = "MF_EMAIL_PORT"
@ -123,6 +126,7 @@ type config struct {
authTimeout time.Duration
adminEmail string
adminPassword string
passRegex *regexp.Regexp
}
func main() {
@ -175,6 +179,11 @@ func loadConfig() config {
log.Fatalf("Invalid value passed for %s\n", envAuthTLS)
}
passRegex, err := regexp.Compile(mainflux.Env(envPassRegex, defPassRegex))
if err != nil {
log.Fatalf("Invalid password validation rules %s\n", envPassRegex)
}
dbConfig := postgres.Config{
Host: mainflux.Env(envDBHost, defDBHost),
Port: mainflux.Env(envDBPort, defDBPort),
@ -213,6 +222,7 @@ func loadConfig() config {
authTimeout: authTimeout,
adminEmail: mainflux.Env(envAdminEmail, defAdminEmail),
adminPassword: mainflux.Env(envAdminPassword, defAdminPassword),
passRegex: passRegex,
}
}
@ -287,7 +297,7 @@ func newService(db *sqlx.DB, tracer opentracing.Tracer, auth mainflux.AuthServic
idProvider := uuid.New()
svc := users.New(userRepo, groupRepo, hasher, auth, emailer, idProvider)
svc := users.New(userRepo, groupRepo, hasher, auth, emailer, idProvider, c.passRegex)
svc = api.LoggingMiddleware(svc, logger)
svc = api.MetricsMiddleware(
svc,

View File

@ -8,6 +8,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"github.com/mainflux/mainflux"
@ -24,6 +25,10 @@ const (
invalidEmail = "userexample.com"
)
var (
passRegex = regexp.MustCompile("^.{8,}$")
)
func newUserService() users.Service {
usersRepo := mocks.NewUserRepository()
groupsRepo := mocks.NewGroupRepository()
@ -32,7 +37,7 @@ func newUserService() users.Service {
emailer := mocks.NewEmailer()
idProvider := uuid.New()
return users.New(usersRepo, groupsRepo, hasher, auth, emailer, idProvider)
return users.New(usersRepo, groupsRepo, hasher, auth, emailer, idProvider, passRegex)
}
func newUserServer(svc users.Service) *httptest.Server {

View File

@ -38,7 +38,7 @@ done
###
# Users
###
MF_USERS_LOG_LEVEL=info MF_EMAIL_TEMPLATE=../docker/users/emailer/templates/email.tmpl $BUILD_DIR/mainflux-users &
MF_USERS_LOG_LEVEL=info MF_EMAIL_TEMPLATE=../docker/templates/users.tmpl $BUILD_DIR/mainflux-users &
###
# Things

View File

@ -11,6 +11,7 @@ import (
"io/ioutil"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
@ -27,17 +28,22 @@ import (
const (
contentType = "application/json"
validEmail = "user@example.com"
invalidEmail = "userexample.com"
validPass = "password"
invalidPass = "wrong"
)
var (
user = users.User{Email: "user@example.com", Password: "password"}
user = users.User{Email: validEmail, Password: validPass}
notFoundRes = toJSON(errorRes{users.ErrUserNotFound.Error()})
unauthRes = toJSON(errorRes{users.ErrUnauthorizedAccess.Error()})
malformedRes = toJSON(errorRes{users.ErrMalformedEntity.Error()})
weakPassword = toJSON(errorRes{users.ErrPasswordFormat.Error()})
unsupportedRes = toJSON(errorRes{api.ErrUnsupportedContentType.Error()})
failDecodeRes = toJSON(errorRes{api.ErrFailedDecode.Error()})
groupExists = toJSON(errorRes{users.ErrGroupConflict.Error()})
passRegex = regexp.MustCompile("^.{8,}$")
)
type testRequest struct {
@ -73,7 +79,7 @@ func newService() users.Service {
email := mocks.NewEmailer()
idProvider := uuid.New()
return users.New(usersRepo, groupRepo, hasher, auth, email, idProvider)
return users.New(usersRepo, groupRepo, hasher, auth, email, idProvider, passRegex)
}
func newServer(svc users.Service) *httptest.Server {
@ -93,7 +99,8 @@ func TestRegister(t *testing.T) {
client := ts.Client()
data := toJSON(user)
invalidData := toJSON(users.User{Email: invalidEmail, Password: "password"})
invalidData := toJSON(users.User{Email: invalidEmail, Password: validPass})
invalidPasswordData := toJSON(users.User{Email: validEmail, Password: invalidPass})
invalidFieldData := fmt.Sprintf(`{"email": "%s", "pass": "%s"}`, user.Email, user.Password)
cases := []struct {
@ -105,6 +112,7 @@ func TestRegister(t *testing.T) {
{"register new user", data, contentType, http.StatusCreated},
{"register existing user", data, contentType, http.StatusConflict},
{"register user with invalid email address", invalidData, contentType, http.StatusBadRequest},
{"register user with weak password", invalidPasswordData, contentType, http.StatusBadRequest},
{"register user with invalid request format", "{", contentType, http.StatusBadRequest},
{"register user with empty JSON request", "{}", contentType, http.StatusBadRequest},
{"register user with empty request", "", contentType, http.StatusBadRequest},
@ -138,15 +146,15 @@ func TestLogin(t *testing.T) {
data := toJSON(user)
invalidEmailData := toJSON(users.User{
Email: invalidEmail,
Password: "password",
Password: validPass,
})
invalidData := toJSON(users.User{
Email: "user@example.com",
Email: validEmail,
Password: "invalid_password",
})
nonexistentData := toJSON(users.User{
Email: "non-existentuser@example.com",
Password: "password",
Password: validPass,
})
_, err := svc.Register(context.Background(), user)
require.Nil(t, err, fmt.Sprintf("register user got unexpected error: %s", err))
@ -235,7 +243,7 @@ func TestPasswordResetRequest(t *testing.T) {
nonexistentData := toJSON(users.User{
Email: "non-existentuser@example.com",
Password: "password",
Password: validPass,
})
expectedExisting := toJSON(struct {
@ -322,9 +330,12 @@ func TestPasswordReset(t *testing.T) {
reqData.Token = token
reqData.ConfPass = "wrong"
reqData.ConfPass = invalidPass
reqPassNoMatch := toJSON(reqData)
reqData.Password = invalidPass
reqPassWeak := toJSON(reqData)
cases := []struct {
desc string
req string
@ -340,6 +351,7 @@ func TestPasswordReset(t *testing.T) {
{"password reset request with empty JSON request", "{}", contentType, http.StatusBadRequest, malformedRes, token},
{"password reset request with empty request", "", contentType, http.StatusBadRequest, failDecodeRes, token},
{"password reset request with missing content type", reqExisting, "", http.StatusUnsupportedMediaType, unsupportedRes, token},
{"password reset with weak password", reqPassWeak, contentType, http.StatusBadRequest, weakPassword, token},
}
for _, tc := range cases {
@ -395,9 +407,13 @@ func TestPasswordChange(t *testing.T) {
reqNoExist := toJSON(reqData)
reqData.OldPassw = "wrong"
reqData.OldPassw = invalidPass
reqWrongPass := toJSON(reqData)
reqData.OldPassw = user.Password
reqData.Password = invalidPass
reqWeakPass := toJSON(reqData)
resData.Msg = users.ErrUnauthorizedAccess.Error()
cases := []struct {
@ -411,6 +427,7 @@ func TestPasswordChange(t *testing.T) {
{"password change with valid token", dataResExisting, contentType, http.StatusCreated, expectedSuccess, token},
{"password change with invalid token", reqNoExist, contentType, http.StatusForbidden, unauthRes, ""},
{"password change with invalid old password", reqWrongPass, contentType, http.StatusForbidden, unauthRes, token},
{"password change with invalid new password", reqWeakPass, contentType, http.StatusBadRequest, weakPassword, token},
{"password change with empty JSON request", "{}", contentType, http.StatusBadRequest, malformedRes, token},
{"password change empty request", "", contentType, http.StatusBadRequest, failDecodeRes, token},
{"password change missing content type", dataResExisting, "", http.StatusUnsupportedMediaType, unsupportedRes, token},

View File

@ -8,7 +8,6 @@ import (
)
const (
minPassLen = 8
maxNameSize = 1024
)
@ -100,11 +99,8 @@ func (req passwChangeReq) validate() error {
if req.Token == "" {
return users.ErrUnauthorizedAccess
}
if len(req.Password) < minPassLen {
return users.ErrMalformedEntity
}
if req.OldPassword == "" {
return users.ErrUnauthorizedAccess
return users.ErrMalformedEntity
}
return nil
}

View File

@ -432,6 +432,8 @@ func encodeError(_ context.Context, err error, w http.ResponseWriter) {
w.WriteHeader(http.StatusBadRequest)
case errors.Contains(errorVal, users.ErrRecoveryToken):
w.WriteHeader(http.StatusNotFound)
case errors.Contains(errorVal, users.ErrPasswordFormat):
w.WriteHeader(http.StatusBadRequest)
default:
w.WriteHeader(http.StatusInternalServerError)
}

View File

@ -66,6 +66,9 @@ var (
// ErrAssignUserToGroup indicates an error in assigning user to a group.
ErrAssignUserToGroup = errors.New("failed assigning user to a group")
// ErrPasswordFormat indicates weak password.
ErrPasswordFormat = errors.New("password does not meet the requirements")
)
// Service specifies an API that must be fullfiled by the domain service
@ -164,10 +167,11 @@ type usersService struct {
email Emailer
auth mainflux.AuthServiceClient
idProvider mainflux.IDProvider
passRegex *regexp.Regexp
}
// New instantiates the users service implementation
func New(users UserRepository, groups GroupRepository, hasher Hasher, auth mainflux.AuthServiceClient, m Emailer, idp mainflux.IDProvider) Service {
func New(users UserRepository, groups GroupRepository, hasher Hasher, auth mainflux.AuthServiceClient, m Emailer, idp mainflux.IDProvider, passRegex *regexp.Regexp) Service {
return &usersService{
users: users,
groups: groups,
@ -175,6 +179,7 @@ func New(users UserRepository, groups GroupRepository, hasher Hasher, auth mainf
auth: auth,
email: m,
idProvider: idp,
passRegex: passRegex,
}
}
@ -182,6 +187,9 @@ func (svc usersService) Register(ctx context.Context, user User) (string, error)
if err := user.Validate(); err != nil {
return "", err
}
if !svc.passRegex.MatchString(user.Password) {
return "", ErrPasswordFormat
}
hash, err := svc.hasher.Hash(user.Password)
if err != nil {
return "", errors.Wrap(ErrMalformedEntity, err)
@ -290,6 +298,9 @@ func (svc usersService) ResetPassword(ctx context.Context, resetToken, password
if err != nil || u.Email == "" {
return ErrUserNotFound
}
if !svc.passRegex.MatchString(password) {
return ErrPasswordFormat
}
password, err = svc.hasher.Hash(password)
if err != nil {
return err
@ -302,6 +313,9 @@ func (svc usersService) ChangePassword(ctx context.Context, authToken, password,
if err != nil {
return errors.Wrap(ErrUnauthorizedAccess, err)
}
if !svc.passRegex.MatchString(password) {
return ErrPasswordFormat
}
u := User{
Email: email,
Password: oldPassword,

View File

@ -6,6 +6,7 @@ package users_test
import (
"context"
"fmt"
"regexp"
"testing"
"github.com/mainflux/mainflux"
@ -27,6 +28,7 @@ var (
groupName = "Mainflux"
idProvider = uuid.New()
passRegex = regexp.MustCompile("^.{8,}$")
)
func newService() users.Service {
@ -36,7 +38,7 @@ func newService() users.Service {
auth := mocks.NewAuthService(map[string]string{user.Email: user.Email})
e := mocks.NewEmailer()
return users.New(userRepo, groupRepo, hasher, auth, e, idProvider)
return users.New(userRepo, groupRepo, hasher, auth, e, idProvider, passRegex)
}
func TestRegister(t *testing.T) {
@ -58,12 +60,12 @@ func TestRegister(t *testing.T) {
err: users.ErrConflict,
},
{
desc: "register new user with empty password",
desc: "register new user with weak password",
user: users.User{
Email: user.Email,
Password: "",
Password: "weak",
},
err: users.ErrMalformedEntity,
err: users.ErrPasswordFormat,
},
}

View File

@ -13,7 +13,6 @@ import (
)
const (
minPassLen = 8
maxLocalLen = 64
maxDomainLen = 255
maxTLDLen = 24 // longest TLD currently in existence
@ -46,11 +45,6 @@ func (u User) Validate() error {
if !isEmail(u.Email) {
return ErrMalformedEntity
}
if len(u.Password) < minPassLen {
return ErrMalformedEntity
}
return nil
}

View File

@ -72,13 +72,6 @@ func TestValidate(t *testing.T) {
},
err: users.ErrMalformedEntity,
},
"validate user with empty password": {
user: users.User{
Email: email,
Password: "",
},
err: users.ErrMalformedEntity,
},
"validate user with invalid email": {
user: users.User{
Email: "userexample.com",