From 0568006a2789037c58e8660514e7df345383c9bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 28 Oct 2020 14:35:41 +0100 Subject: [PATCH] Auth WIP --- server/model/user.go | 15 ++ server/services/auth/authentication.go | 14 ++ server/services/auth/password.go | 126 +++++++++++++++ server/services/auth/password_test.go | 166 ++++++++++++++++++++ server/services/auth/request_parser.go | 67 ++++++++ server/services/auth/request_parser_test.go | 48 ++++++ server/services/store/sqlstore/user.go | 48 ++++++ 7 files changed, 484 insertions(+) create mode 100644 server/model/user.go create mode 100644 server/services/auth/authentication.go create mode 100644 server/services/auth/password.go create mode 100644 server/services/auth/password_test.go create mode 100644 server/services/auth/request_parser.go create mode 100644 server/services/auth/request_parser_test.go create mode 100644 server/services/store/sqlstore/user.go diff --git a/server/model/user.go b/server/model/user.go new file mode 100644 index 000000000..0b9f1273b --- /dev/null +++ b/server/model/user.go @@ -0,0 +1,15 @@ +package model + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"-"` + MfaSecret string `json:"-"` + AuthService string `json:"-"` + AuthData string `json:"-"` + Props map[string]interface{} `json:"props"` + CreateAt int64 `json:"create_at,omitempty"` + UpdateAt int64 `json:"update_at,omitempty"` + DeleteAt int64 `json:"delete_at"` +} diff --git a/server/services/auth/authentication.go b/server/services/auth/authentication.go new file mode 100644 index 000000000..519c4f7e3 --- /dev/null +++ b/server/services/auth/authentication.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package auth + +type AuthService struct { + passwordSettings PasswordSettings +} + +func New(passwordSettings PasswordSettings) *AuthService { + return &AuthService{ + passwordSettings: passwordSettings, + } +} diff --git a/server/services/auth/password.go b/server/services/auth/password.go new file mode 100644 index 000000000..e5925ad06 --- /dev/null +++ b/server/services/auth/password.go @@ -0,0 +1,126 @@ +package auth + +import ( + "fmt" + "math/rand" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +var passwordRandomSource = rand.NewSource(time.Now().Unix()) + +const ( + PasswordMaximumLength = 64 + PasswordSpecialChars = "!\"\\#$%&'()*+,-./:;<=>?@[]^_`|~" + PasswordNumbers = "0123456789" + PasswordUpperCaseLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + PasswordLowerCaseLetters = "abcdefghijklmnopqrstuvwxyz" + PasswordAllChars = PasswordSpecialChars + PasswordNumbers + PasswordUpperCaseLetters + PasswordLowerCaseLetters + + InvalidLowercasePassword = "lowercase" + InvalidMinLengthPassword = "min-length" + InvalidMaxLengthPassword = "max-length" + InvalidNumberPassword = "number" + InvalidUppercasePassword = "uppercase" + InvalidSymbolPassword = "symbol" +) + +// HashPassword generates a hash using the bcrypt.GenerateFromPassword +func HashPassword(password string) string { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) + if err != nil { + panic(err) + } + + return string(hash) +} + +// ComparePassword compares the hash +func ComparePassword(hash string, password string) bool { + + if len(password) == 0 || len(hash) == 0 { + return false + } + + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func GeneratePassword(minimumLength int) string { + r := rand.New(passwordRandomSource) + + // Make sure we are guaranteed at least one of each type to meet any possible password complexity requirements. + password := string([]rune(PasswordUpperCaseLetters)[r.Intn(len(PasswordUpperCaseLetters))]) + + string([]rune(PasswordNumbers)[r.Intn(len(PasswordNumbers))]) + + string([]rune(PasswordLowerCaseLetters)[r.Intn(len(PasswordLowerCaseLetters))]) + + string([]rune(PasswordSpecialChars)[r.Intn(len(PasswordSpecialChars))]) + + for len(password) < minimumLength { + i := r.Intn(len(PasswordAllChars)) + password = password + string([]rune(PasswordAllChars)[i]) + } + + return password +} + +type InvalidPasswordError struct { + FailingCriterias []string +} + +func (ipe *InvalidPasswordError) Error() string { + return fmt.Sprintf("invalid password, failing criterias: %s", strings.Join(ipe.FailingCriterias, ", ")) +} + +type PasswordSettings struct { + MinimumLength int + Lowercase bool + Number bool + Uppercase bool + Symbol bool +} + +func (as *AuthService) IsPasswordValid(password string) error { + err := &InvalidPasswordError{ + FailingCriterias: []string{}, + } + + if len(password) < as.passwordSettings.MinimumLength { + err.FailingCriterias = append(err.FailingCriterias, InvalidMinLengthPassword) + } + + if len(password) > PasswordMaximumLength { + err.FailingCriterias = append(err.FailingCriterias, InvalidMaxLengthPassword) + } + + if as.passwordSettings.Lowercase { + if !strings.ContainsAny(password, PasswordLowerCaseLetters) { + err.FailingCriterias = append(err.FailingCriterias, InvalidLowercasePassword) + } + } + + if as.passwordSettings.Uppercase { + if !strings.ContainsAny(password, PasswordUpperCaseLetters) { + err.FailingCriterias = append(err.FailingCriterias, InvalidUppercasePassword) + } + } + + if as.passwordSettings.Number { + if !strings.ContainsAny(password, PasswordNumbers) { + err.FailingCriterias = append(err.FailingCriterias, InvalidNumberPassword) + } + } + + if as.passwordSettings.Symbol { + if !strings.ContainsAny(password, PasswordSpecialChars) { + err.FailingCriterias = append(err.FailingCriterias, InvalidSymbolPassword) + } + } + + if len(err.FailingCriterias) > 0 { + return err + } + + return nil +} diff --git a/server/services/auth/password_test.go b/server/services/auth/password_test.go new file mode 100644 index 000000000..eb5af8e7f --- /dev/null +++ b/server/services/auth/password_test.go @@ -0,0 +1,166 @@ +package auth + +import ( + "math/rand" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPasswordHash(t *testing.T) { + hash := HashPassword("Test") + + assert.True(t, ComparePassword(hash, "Test"), "Passwords don't match") + assert.False(t, ComparePassword(hash, "Test2"), "Passwords should not have matched") +} + +func TestGeneratePassword(t *testing.T) { + passwordRandomSource = rand.NewSource(12345) + + t.Run("Should be the minimum length or 4, whichever is less", func(t *testing.T) { + password1 := GeneratePassword(5) + assert.Len(t, password1, 5) + password2 := GeneratePassword(10) + assert.Len(t, password2, 10) + password3 := GeneratePassword(1) + assert.Len(t, password3, 4) + }) + + t.Run("Should contain at least one of symbols, upper case, lower case and numbers", func(t *testing.T) { + password := GeneratePassword(4) + require.Len(t, password, 4) + assert.Contains(t, []rune(PasswordUpperCaseLetters), []rune(password)[0]) + assert.Contains(t, []rune(PasswordNumbers), []rune(password)[1]) + assert.Contains(t, []rune(PasswordLowerCaseLetters), []rune(password)[2]) + assert.Contains(t, []rune(PasswordSpecialChars), []rune(password)[3]) + }) +} + +func TestIsPasswordValidWithSettings(t *testing.T) { + for name, tc := range map[string]struct { + Password string + Settings PasswordSettings + ExpectedFailingCriterias []string + }{ + "Short": { + Password: strings.Repeat("x", 3), + Settings: PasswordSettings{ + MinimumLength: 3, + Lowercase: false, + Uppercase: false, + Number: false, + Symbol: false, + }, + }, + "Long": { + Password: strings.Repeat("x", PasswordMaximumLength), + Settings: PasswordSettings{ + MinimumLength: 3, + Lowercase: false, + Uppercase: false, + Number: false, + Symbol: false, + }, + }, + "TooShort": { + Password: strings.Repeat("x", 2), + Settings: PasswordSettings{ + MinimumLength: 3, + Lowercase: false, + Uppercase: false, + Number: false, + Symbol: false, + }, + ExpectedFailingCriterias: []string{"min-length"}, + }, + "TooLong": { + Password: strings.Repeat("x", PasswordMaximumLength+1), + Settings: PasswordSettings{ + MinimumLength: 3, + Lowercase: false, + Uppercase: false, + Number: false, + Symbol: false, + }, + ExpectedFailingCriterias: []string{"max-length"}, + }, + "MissingLower": { + Password: "AAAAAAAAAAASD123!@#", + Settings: PasswordSettings{ + MinimumLength: 3, + Lowercase: true, + Uppercase: false, + Number: false, + Symbol: false, + }, + ExpectedFailingCriterias: []string{"lowercase"}, + }, + "MissingUpper": { + Password: "aaaaaaaaaaaaasd123!@#", + Settings: PasswordSettings{ + MinimumLength: 3, + Uppercase: true, + Lowercase: false, + Number: false, + Symbol: false, + }, + ExpectedFailingCriterias: []string{"uppercase"}, + }, + "MissingNumber": { + Password: "asasdasdsadASD!@#", + Settings: PasswordSettings{ + MinimumLength: 3, + Number: true, + Lowercase: false, + Uppercase: false, + Symbol: false, + }, + ExpectedFailingCriterias: []string{"number"}, + }, + "MissingSymbol": { + Password: "asdasdasdasdasdASD123", + Settings: PasswordSettings{ + MinimumLength: 3, + Symbol: true, + Lowercase: false, + Uppercase: false, + Number: false, + }, + ExpectedFailingCriterias: []string{"symbol"}, + }, + "MissingMultiple": { + Password: "asdasdasdasdasdasd", + Settings: PasswordSettings{ + MinimumLength: 3, + Lowercase: true, + Uppercase: true, + Number: true, + Symbol: true, + }, + ExpectedFailingCriterias: []string{"uppercase", "number", "symbol"}, + }, + "Everything": { + Password: "asdASD!@#123", + Settings: PasswordSettings{ + MinimumLength: 3, + Lowercase: true, + Uppercase: true, + Number: true, + Symbol: true, + }, + }, + } { + t.Run(name, func(t *testing.T) { + as := New(tc.Settings) + err := as.IsPasswordValid(tc.Password) + if len(tc.ExpectedFailingCriterias) == 0 { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.Equal(t, tc.ExpectedFailingCriterias, err.(*InvalidPasswordError).FailingCriterias) + } + }) + } +} diff --git a/server/services/auth/request_parser.go b/server/services/auth/request_parser.go new file mode 100644 index 000000000..a0f073da1 --- /dev/null +++ b/server/services/auth/request_parser.go @@ -0,0 +1,67 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package auth + +import ( + "net/http" + "strings" +) + +const ( + HEADER_TOKEN = "token" + HEADER_AUTH = "Authorization" + HEADER_BEARER = "BEARER" + SESSION_COOKIE_TOKEN = "MMAUTHTOKEN" +) + +type TokenLocation int + +const ( + TokenLocationNotFound TokenLocation = iota + TokenLocationHeader + TokenLocationCookie + TokenLocationQueryString +) + +func (tl TokenLocation) String() string { + switch tl { + case TokenLocationNotFound: + return "Not Found" + case TokenLocationHeader: + return "Header" + case TokenLocationCookie: + return "Cookie" + case TokenLocationQueryString: + return "QueryString" + default: + return "Unknown" + } +} + +func ParseAuthTokenFromRequest(r *http.Request) (string, TokenLocation) { + authHeader := r.Header.Get(HEADER_AUTH) + + // Attempt to parse the token from the cookie + if cookie, err := r.Cookie(SESSION_COOKIE_TOKEN); err == nil { + return cookie.Value, TokenLocationCookie + } + + // Parse the token from the header + if len(authHeader) > 6 && strings.ToUpper(authHeader[0:6]) == HEADER_BEARER { + // Default session token + return authHeader[7:], TokenLocationHeader + } + + if len(authHeader) > 5 && strings.ToLower(authHeader[0:5]) == HEADER_TOKEN { + // OAuth token + return authHeader[6:], TokenLocationHeader + } + + // Attempt to parse token out of the query string + if token := r.URL.Query().Get("access_token"); token != "" { + return token, TokenLocationQueryString + } + + return "", TokenLocationNotFound +} diff --git a/server/services/auth/request_parser_test.go b/server/services/auth/request_parser_test.go new file mode 100644 index 000000000..fceeed8f4 --- /dev/null +++ b/server/services/auth/request_parser_test.go @@ -0,0 +1,48 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseAuthTokenFromRequest(t *testing.T) { + cases := []struct { + header string + cookie string + query string + expectedToken string + expectedLocation TokenLocation + }{ + {"", "", "", "", TokenLocationNotFound}, + {"token mytoken", "", "", "mytoken", TokenLocationHeader}, + {"BEARER mytoken", "", "", "mytoken", TokenLocationHeader}, + {"", "mytoken", "", "mytoken", TokenLocationCookie}, + {"", "", "mytoken", "mytoken", TokenLocationQueryString}, + } + + for testnum, tc := range cases { + pathname := "/test/here" + if tc.query != "" { + pathname += "?access_token=" + tc.query + } + req := httptest.NewRequest("GET", pathname, nil) + if tc.header != "" { + req.Header.Add(HEADER_AUTH, tc.header) + } + if tc.cookie != "" { + req.AddCookie(&http.Cookie{ + Name: SESSION_COOKIE_TOKEN, + Value: tc.cookie, + }) + } + + token, location := ParseAuthTokenFromRequest(req) + + require.Equal(t, tc.expectedToken, token, "Wrong token on test "+strconv.Itoa(testnum)) + require.Equal(t, tc.expectedLocation, location, "Wrong location on test "+strconv.Itoa(testnum)) + } +} diff --git a/server/services/store/sqlstore/user.go b/server/services/store/sqlstore/user.go new file mode 100644 index 000000000..ee380d5e4 --- /dev/null +++ b/server/services/store/sqlstore/user.go @@ -0,0 +1,48 @@ +package sqlstore + +import ( + "time" + + "github.com/mattermost/mattermost-octo-tasks/server/model" + + sq "github.com/Masterminds/squirrel" +) + +func (s *SQLStore) getUserByCondition(condition sq.Eq) (*model.User, error) { + query := s.getQueryBuilder(). + Select("id", "username", "email", "password", "mfa_secret", "auth_service", "auth_data", "props", "create_at", "update_at", "delete_at"). + From("users"). + Where(condition) + row := query.QueryRow() + user := model.User{} + + err := row.Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.MfaSecret, &user.AuthService, &user.AuthData, &user.Props, &user.CreateAt, &user.UpdateAt, &user.DeleteAt) + if err != nil { + return nil, err + } + + return &user, nil +} + +func (s *SQLStore) GetUserById(userID string) (*model.User, error) { + return s.getUserByCondition(sq.Eq{"id": userID}) +} + +func (s *SQLStore) GetUserByEmail(email string) (*model.User, error) { + return s.getUserByCondition(sq.Eq{"email": email}) +} + +func (s *SQLStore) GetUserByUsername(username string) (*model.User, error) { + return s.getUserByCondition(sq.Eq{"username": username}) +} + +func (s *SQLStore) CreateUser(user *model.User) error { + now := time.Now().Unix() + + query := s.getQueryBuilder().Insert("users"). + Columns("id", "username", "email", "password", "mfa_secret", "auth_service", "auth_data", "props", "create_at", "update_at", "delete_at"). + Values(user.ID, user.Username, user.Email, user.Password, user.MfaSecret, user.AuthService, user.AuthData, user.Props, now, now, 0) +} + +func (s *SQLStore) UpdateUser(user *model.User) error { +}