This commit is contained in:
Jesús Espino 2020-10-28 14:35:41 +01:00
parent ec93778293
commit 0568006a27
7 changed files with 484 additions and 0 deletions

15
server/model/user.go Normal file
View file

@ -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"`
}

View file

@ -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,
}
}

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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
}

View file

@ -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))
}
}

View file

@ -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 {
}