Auth WIP
This commit is contained in:
parent
ec93778293
commit
0568006a27
7 changed files with 484 additions and 0 deletions
15
server/model/user.go
Normal file
15
server/model/user.go
Normal 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"`
|
||||
}
|
14
server/services/auth/authentication.go
Normal file
14
server/services/auth/authentication.go
Normal 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,
|
||||
}
|
||||
}
|
126
server/services/auth/password.go
Normal file
126
server/services/auth/password.go
Normal 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
|
||||
}
|
166
server/services/auth/password_test.go
Normal file
166
server/services/auth/password_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
67
server/services/auth/request_parser.go
Normal file
67
server/services/auth/request_parser.go
Normal 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
|
||||
}
|
48
server/services/auth/request_parser_test.go
Normal file
48
server/services/auth/request_parser_test.go
Normal 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))
|
||||
}
|
||||
}
|
48
server/services/store/sqlstore/user.go
Normal file
48
server/services/store/sqlstore/user.go
Normal 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 {
|
||||
}
|
Loading…
Reference in a new issue