Adds endpoints to invite new users to the application. (#235)

User service checks that tokens are valid on new user signups.
Closes #230
This commit is contained in:
Victor Vrantchan
2016-09-28 22:44:05 -04:00
committed by GitHub
parent 738d7253c2
commit 6fb96d98f7
33 changed files with 1124 additions and 241 deletions
+8 -11
View File
@@ -85,7 +85,7 @@ the way that the kolide server works.
// Bootstrap a few users when using the in-memory database.
// Each user's default password will just be their username.
users := []kolide.User{
kolide.User{
{
Name: "Admin User",
Username: "admin",
Email: "admin@kolide.co",
@@ -93,7 +93,7 @@ the way that the kolide server works.
Admin: true,
Enabled: true,
},
kolide.User{
{
Name: "Normal User",
Username: "user",
Email: "user@kolide.co",
@@ -104,15 +104,12 @@ the way that the kolide server works.
}
for _, user := range users {
_, err := svc.NewUser(ctx, kolide.UserPayload{
Name: &user.Name,
Username: &user.Username,
Password: &user.Username,
Email: &user.Email,
Enabled: &user.Enabled,
Position: &user.Position,
Admin: &user.Admin,
})
user := user
err := user.SetPassword(user.Username, config.Auth.SaltKeySize, config.Auth.BcryptCost)
if err != nil {
initFatal(err, "creating bootstrap user")
}
_, err = ds.NewUser(&user)
if err != nil {
initFatal(err, "creating bootstrap user")
}
+16 -5
View File
@@ -38,7 +38,10 @@ type AuthConfig struct {
// AppConfig defines configs related to HTTP
type AppConfig struct {
WebAddress string
WebAddress string
TokenKeySize int
TokenKey string
InviteTokenValidityPeriod time.Duration
}
// SMTPConfig defines configs related to SMTP email
@@ -47,7 +50,6 @@ type SMTPConfig struct {
Username string
Password string
PoolConnections int
TokenKeySize int
}
// SessionConfig defines configs related to user sessions
@@ -107,13 +109,15 @@ func (man Manager) addConfigs() {
// App
man.addConfigString("app.web_address", "0.0.0.0:8080")
man.addConfigString("app.token_key", "CHANGEME")
man.addConfigDuration("app.invite_token_validity_period", 5*24*time.Hour)
man.addConfigInt("app.token_key_size", 24)
// SMTP
man.addConfigString("smtp.server", "0.0.0.0:1025")
man.addConfigString("smtp.username", "")
man.addConfigString("smtp.password", "")
man.addConfigInt("smtp.pool_connections", 4)
man.addConfigInt("smtp.token_key_size", 24)
// Session
man.addConfigInt("session.key_size", 64)
@@ -154,14 +158,16 @@ func (man Manager) LoadConfig() KolideConfig {
SaltKeySize: man.getConfigInt("auth.salt_key_size"),
},
App: AppConfig{
WebAddress: man.getConfigString("app.web_address"),
WebAddress: man.getConfigString("app.web_address"),
TokenKeySize: man.getConfigInt("app.token_key_size"),
TokenKey: man.getConfigString("app.token_key"),
InviteTokenValidityPeriod: man.getConfigDuration("app.invite_token_validity_period"),
},
SMTP: SMTPConfig{
Server: man.getConfigString("smtp.server"),
Username: man.getConfigString("smtp.username"),
Password: man.getConfigString("smtp.password"),
PoolConnections: man.getConfigInt("smtp.pool_connections"),
TokenKeySize: man.getConfigInt("smtp.token_key_size"),
},
Session: SessionConfig{
KeySize: man.getConfigInt("session.key_size"),
@@ -350,6 +356,10 @@ func (man Manager) loadConfigFile() {
// Individual tests may want to override some of the values provided.
func TestConfig() KolideConfig {
return KolideConfig{
App: AppConfig{
TokenKey: "CHANGEME",
InviteTokenValidityPeriod: 5 * 24 * time.Hour,
},
Auth: AuthConfig{
JwtKey: "CHANGEME",
BcryptCost: 6, // Low cost keeps tests fast
@@ -370,5 +380,6 @@ func TestConfig() KolideConfig {
Debug: true,
DisableBanner: true,
},
SMTP: SMTPConfig{},
}
}
+1
View File
@@ -75,6 +75,7 @@ func New(driver, conn string, opts ...DBOption) (kolide.Datastore, error) {
users: make(map[uint]*kolide.User),
sessions: make(map[uint]*kolide.Session),
passwordResets: make(map[uint]*kolide.PasswordResetRequest),
invites: make(map[uint]*kolide.Invite),
}
return ds, nil
default:
+1
View File
@@ -35,6 +35,7 @@ var tables = [...]interface{}{
&kolide.Query{},
&kolide.DistributedQueryExecution{},
&kolide.OrgInfo{},
&kolide.Invite{},
}
type gormDB struct {
+48
View File
@@ -0,0 +1,48 @@
package datastore
import "github.com/kolide/kolide-ose/server/kolide"
func (orm gormDB) NewInvite(invite *kolide.Invite) (*kolide.Invite, error) {
err := orm.DB.Create(invite).Error
if err != nil {
return nil, err
}
return invite, nil
}
func (orm gormDB) InviteByEmail(email string) (*kolide.Invite, error) {
invite := &kolide.Invite{
Email: email,
}
err := orm.DB.Where("email = ?", email).First(invite).Error
if err != nil {
return nil, err
}
return invite, nil
}
func (orm gormDB) Invites() ([]*kolide.Invite, error) {
var invites []*kolide.Invite
err := orm.DB.Find(&invites).Error
if err != nil {
return nil, err
}
return invites, nil
}
func (orm gormDB) Invite(id uint) (*kolide.Invite, error) {
invite := &kolide.Invite{ID: id}
err := orm.DB.Where(invite).First(invite).Error
if err != nil {
return nil, err
}
return invite, nil
}
func (orm gormDB) SaveInvite(invite *kolide.Invite) error {
return orm.DB.Save(invite).Error
}
func (orm gormDB) DeleteInvite(invite *kolide.Invite) error {
return orm.DB.Delete(invite).Error
}
+30
View File
@@ -0,0 +1,30 @@
package datastore
import (
"os"
"testing"
"github.com/kolide/kolide-ose/server/kolide"
"github.com/stretchr/testify/assert"
)
func TestCreateInvite(t *testing.T) {
var ds kolide.Datastore
address := os.Getenv("MYSQL_ADDR")
if address == "" {
ds = setup(t)
} else {
ds = setupMySQLGORM(t)
defer teardownMySQLGORM(t, ds)
}
invite := &kolide.Invite{}
invite, err := ds.NewInvite(invite)
assert.Nil(t, err)
verify, err := ds.InviteByEmail(invite.Email)
assert.Nil(t, err)
assert.Equal(t, invite.ID, verify.ID)
assert.Equal(t, invite.Email, verify.Email)
}
+1
View File
@@ -13,6 +13,7 @@ type inmem struct {
users map[uint]*kolide.User
sessions map[uint]*kolide.Session
passwordResets map[uint]*kolide.PasswordResetRequest
invites map[uint]*kolide.Invite
orginfo *kolide.OrgInfo
}
+79
View File
@@ -0,0 +1,79 @@
package datastore
import "github.com/kolide/kolide-ose/server/kolide"
// NewInvite creates and stores a new invitation in a DB.
func (orm *inmem) NewInvite(invite *kolide.Invite) (*kolide.Invite, error) {
orm.mtx.Lock()
defer orm.mtx.Unlock()
for _, in := range orm.invites {
if in.Email == invite.Email {
return nil, ErrExists
}
}
invite.ID = uint(len(orm.invites) + 1)
orm.invites[invite.ID] = invite
return invite, nil
}
// Invites lists all invites in the datastore.
func (orm *inmem) Invites() ([]*kolide.Invite, error) {
orm.mtx.Lock()
defer orm.mtx.Unlock()
var invites []*kolide.Invite
for _, invite := range orm.invites {
invites = append(invites, invite)
}
return invites, nil
}
func (orm *inmem) Invite(id uint) (*kolide.Invite, error) {
orm.mtx.Lock()
defer orm.mtx.Unlock()
if invite, ok := orm.invites[id]; ok {
return invite, nil
}
return nil, ErrNotFound
}
// InviteByEmail retrieves an invite for a specific email address.
func (orm *inmem) InviteByEmail(email string) (*kolide.Invite, error) {
orm.mtx.Lock()
defer orm.mtx.Unlock()
for _, invite := range orm.invites {
if invite.Email == email {
return invite, nil
}
}
return nil, ErrNotFound
}
// SaveInvite saves an invitation in the datastore.
func (orm *inmem) SaveInvite(invite *kolide.Invite) error {
orm.mtx.Lock()
defer orm.mtx.Unlock()
if _, ok := orm.invites[invite.ID]; !ok {
return ErrNotFound
}
orm.invites[invite.ID] = invite
return nil
}
// DeleteInvite deletes an invitation.
func (orm *inmem) DeleteInvite(invite *kolide.Invite) error {
orm.mtx.Lock()
defer orm.mtx.Unlock()
if _, ok := orm.invites[invite.ID]; !ok {
return ErrNotFound
}
delete(orm.invites, invite.ID)
return nil
}
-1
View File
@@ -41,7 +41,6 @@ func (orm *inmem) Users() ([]*kolide.User, error) {
}
return users, nil
}
func (orm *inmem) UserByEmail(email string) (*kolide.User, error) {
+1
View File
@@ -10,6 +10,7 @@ type Datastore interface {
PasswordResetStore
SessionStore
AppConfigStore
InviteStore
Name() string
Drop() error
Migrate() error
+89
View File
@@ -0,0 +1,89 @@
package kolide
import (
"bytes"
"html/template"
"time"
"golang.org/x/net/context"
)
// InviteStore contains the methods for
// managing user invites in a datastore.
type InviteStore interface {
// NewInvite creates and stores a new invitation in a DB.
NewInvite(i *Invite) (*Invite, error)
// Invites lists all invites in the datastore.
Invites() ([]*Invite, error)
// Invite retrieves an invite by it's ID.
Invite(id uint) (*Invite, error)
// InviteByEmail retrieves an invite for a specific email address.
InviteByEmail(email string) (*Invite, error)
// SaveInvite saves an invitation in the datastore.
SaveInvite(i *Invite) error
// DeleteInvite deletes an invitation.
DeleteInvite(i *Invite) error
}
// InviteService contains methods for a service which deals with
// user invites.
type InviteService interface {
// InviteNewUser creates an invite for a new user to join Kolide.
InviteNewUser(ctx context.Context, payload InvitePayload) (invite *Invite, err error)
// DeleteInvite removes an invite.
DeleteInvite(ctx context.Context, id uint) (err error)
// Invites returns a list of all invites.
Invites(ctx context.Context) (invites []*Invite, err error)
// VerifyInvite verifies that an invite exists and that it matches the
// invite token.
VerifyInvite(ctx context.Context, email, token string) (err error)
}
// InvitePayload contains fields required to create a new user invite.
type InvitePayload struct {
InvitedBy *uint `json:"invited_by"`
Email *string
Admin *bool
Name *string
Position *string
}
// Invite represents an invitation for a user to join Kolide.
type Invite struct {
ID uint `gorm:"primary_key"`
CreatedAt time.Time
InvitedBy uint `gorm:"not null"`
Email string `gorm:"not null;unique_index:idx_invite_unique_email"`
Admin bool
Name string
Position string
Token string `gorm:"not null;unique_index:idx_invite_unique_key"`
}
// TODO: fixme
// this is not the right way to generate emails at all
const inviteEmailTempate = `
{{.InvitedBy}} invited you to join Kolide.,
http://localhost:8080/signup?token={{.Token}}
`
func (i Invite) Message() ([]byte, error) {
var msg bytes.Buffer
var err error
t := template.New(inviteEmailTempate)
if t, err = t.Parse(inviteEmailTempate); err != nil {
return nil, err
}
if err = t.Execute(&msg, i); err != nil {
return nil, err
}
return msg.Bytes(), nil
}
+1
View File
@@ -9,4 +9,5 @@ type Service interface {
OsqueryService
HostService
AppConfigService
InviteService
}
+64
View File
@@ -1,6 +1,8 @@
package kolide
import (
"crypto/rand"
"encoding/base64"
"fmt"
"time"
@@ -75,6 +77,32 @@ type UserPayload struct {
Password *string `json:"password"`
GravatarURL *string `json:"gravatar_url"`
Position *string `json:"position"`
InviteToken *string `json:"invite_token"`
}
// User creates a user from payload.
func (p UserPayload) User(keySize, cost int) (*User, error) {
user := &User{
Username: *p.Username,
Email: *p.Email,
Admin: falseIfNil(p.Admin),
AdminForcedPasswordReset: falseIfNil(p.AdminForcedPasswordReset),
Enabled: true,
}
if err := user.SetPassword(*p.Password, keySize, cost); err != nil {
return nil, err
}
// add optional fields
if p.Name != nil {
user.Name = *p.Name
}
if p.Position != nil {
user.Position = *p.Position
}
return user, nil
}
// ValidatePassword accepts a potential password for a given user and attempts
@@ -84,3 +112,39 @@ func (u *User) ValidatePassword(password string) error {
saltAndPass := []byte(fmt.Sprintf("%s%s", password, u.Salt))
return bcrypt.CompareHashAndPassword(u.Password, saltAndPass)
}
func (u *User) SetPassword(plaintext string, keySize, cost int) error {
salt, err := generateRandomText(keySize)
if err != nil {
return err
}
withSalt := []byte(fmt.Sprintf("%s%s", plaintext, salt))
hashed, err := bcrypt.GenerateFromPassword(withSalt, cost)
if err != nil {
return err
}
u.Salt = salt
u.Password = hashed
return nil
}
// generateRandomText return a string generated by filling in keySize bytes with
// random data and then base64 encoding those bytes
func generateRandomText(keySize int) (string, error) {
key := make([]byte, keySize)
_, err := rand.Read(key)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(key), nil
}
// helper to convert a bool pointer false
func falseIfNil(b *bool) bool {
if b == nil {
return false
}
return *b
}
+97
View File
@@ -0,0 +1,97 @@
package service
import (
"github.com/go-kit/kit/endpoint"
"github.com/kolide/kolide-ose/server/kolide"
"golang.org/x/net/context"
)
type createInviteRequest struct {
payload kolide.InvitePayload
}
type createInviteResponse struct {
ID uint `json:"id"`
InvitedBy uint `json:"invited_by"`
Email string `json:"email"`
Name string `json:"name"`
Admin bool `json:"admin"`
Position string `json:"position,omitempty"`
Err error `json:"error,omitempty"`
}
func (r createInviteResponse) error() error { return r.Err }
func makeCreateInviteEndpoint(svc kolide.Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (interface{}, error) {
req := request.(createInviteRequest)
invite, err := svc.InviteNewUser(ctx, req.payload)
if err != nil {
return createInviteResponse{Err: err}, nil
}
return createInviteResponse{
ID: invite.ID,
InvitedBy: invite.InvitedBy,
Email: invite.Email,
Name: invite.Name,
Position: invite.Position,
Admin: invite.Admin,
}, nil
}
}
type inviteResponse struct {
ID uint `json:"id"`
InvitedBy uint `json:"invited_by"`
Email string `json:"email"`
Name string `json:"name"`
Admin bool `json:"admin"`
Position string `json:"position,omitempty"`
}
type listInvitesResponse struct {
Invites []inviteResponse `json:"invites"`
Err error `json:"error,omitempty"`
}
func (r listInvitesResponse) error() error { return r.Err }
func makeListInvitesEndpoint(svc kolide.Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (interface{}, error) {
invites, err := svc.Invites(ctx)
if err != nil {
return listInvitesResponse{Err: err}, nil
}
var resp listInvitesResponse
for _, invite := range invites {
resp.Invites = append(resp.Invites, inviteResponse{
ID: invite.ID,
InvitedBy: invite.InvitedBy,
Email: invite.Email,
Name: invite.Name,
Admin: invite.Admin,
Position: invite.Position,
})
}
return resp, nil
}
}
type deleteInviteRequest struct {
ID uint
}
type deleteInviteResponse struct {
Err error `json:"error,omitempty"`
}
func makeDeleteInviteEndpoint(svc kolide.Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (interface{}, error) {
req := request.(deleteInviteRequest)
err := svc.DeleteInvite(ctx, req.ID)
if err != nil {
return deleteInviteResponse{Err: err}, nil
}
return deleteInviteResponse{}, nil
}
}
+1 -1
View File
@@ -5,9 +5,9 @@ import (
"testing"
"github.com/go-kit/kit/endpoint"
"github.com/kolide/kolide-ose/server/contexts/viewer"
"github.com/kolide/kolide-ose/server/datastore"
"github.com/kolide/kolide-ose/server/kolide"
"github.com/kolide/kolide-ose/server/contexts/viewer"
"github.com/stretchr/testify/assert"
)
+16 -1
View File
@@ -28,6 +28,9 @@ type KolideEndpoints struct {
DeleteSession endpoint.Endpoint
GetAppConfig endpoint.Endpoint
ModifyAppConfig endpoint.Endpoint
CreateInvite endpoint.Endpoint
ListInvites endpoint.Endpoint
DeleteInvite endpoint.Endpoint
GetQuery endpoint.Endpoint
GetAllQueries endpoint.Endpoint
CreateQuery endpoint.Endpoint
@@ -50,8 +53,8 @@ func MakeKolideServerEndpoints(svc kolide.Service, jwtKey string) KolideEndpoint
Logout: makeLogoutEndpoint(svc),
ForgotPassword: makeForgotPasswordEndpoint(svc),
ResetPassword: makeResetPasswordEndpoint(svc),
CreateUser: makeCreateUserEndpoint(svc),
Me: authenticated(jwtKey, svc, makeGetSessionUserEndpoint(svc)),
CreateUser: authenticated(jwtKey, svc, mustBeAdmin(makeCreateUserEndpoint(svc))),
GetUser: authenticated(jwtKey, svc, canReadUser(makeGetUserEndpoint(svc))),
ListUsers: authenticated(jwtKey, svc, canPerformActions(makeListUsersEndpoint(svc))),
ModifyUser: authenticated(jwtKey, svc, validateModifyUserRequest(makeModifyUserEndpoint(svc))),
@@ -61,6 +64,9 @@ func MakeKolideServerEndpoints(svc kolide.Service, jwtKey string) KolideEndpoint
DeleteSession: authenticated(jwtKey, svc, mustBeAdmin(makeDeleteSessionEndpoint(svc))),
GetAppConfig: authenticated(jwtKey, svc, makeGetAppConfigEndpoint(svc)),
ModifyAppConfig: authenticated(jwtKey, svc, mustBeAdmin(makeModifyAppConfigRequest(svc))),
CreateInvite: authenticated(jwtKey, svc, mustBeAdmin(makeCreateInviteEndpoint(svc))),
ListInvites: authenticated(jwtKey, svc, mustBeAdmin(makeListInvitesEndpoint(svc))),
DeleteInvite: authenticated(jwtKey, svc, mustBeAdmin(makeDeleteInviteEndpoint(svc))),
GetQuery: authenticated(jwtKey, svc, makeGetQueryEndpoint(svc)),
GetAllQueries: authenticated(jwtKey, svc, makeGetAllQueriesEndpoint(svc)),
CreateQuery: authenticated(jwtKey, svc, makeCreateQueryEndpoint(svc)),
@@ -93,6 +99,9 @@ type kolideHandlers struct {
DeleteSession *kithttp.Server
GetAppConfig *kithttp.Server
ModifyAppConfig *kithttp.Server
CreateInvite *kithttp.Server
ListInvites *kithttp.Server
DeleteInvite *kithttp.Server
GetQuery *kithttp.Server
GetAllQueries *kithttp.Server
CreateQuery *kithttp.Server
@@ -128,6 +137,9 @@ func makeKolideKitHandlers(ctx context.Context, e KolideEndpoints, opts []kithtt
DeleteSession: newServer(e.DeleteSession, decodeDeleteSessionRequest),
GetAppConfig: newServer(e.GetAppConfig, decodeNoParamsRequest),
ModifyAppConfig: newServer(e.ModifyAppConfig, decodeModifyAppConfigRequest),
CreateInvite: newServer(e.CreateInvite, decodeCreateInviteRequest),
ListInvites: newServer(e.ListInvites, decodeNoParamsRequest),
DeleteInvite: newServer(e.DeleteInvite, decodeDeleteInviteRequest),
GetQuery: newServer(e.GetQuery, decodeGetQueryRequest),
GetAllQueries: newServer(e.GetAllQueries, decodeGetQueryRequest),
CreateQuery: newServer(e.CreateQuery, decodeCreateQueryRequest),
@@ -182,6 +194,9 @@ func attachKolideAPIRoutes(r *mux.Router, h kolideHandlers) {
r.Handle("/api/v1/kolide/sessions/{id}", h.DeleteSession).Methods("DELETE")
r.Handle("/api/v1/kolide/config", h.GetAppConfig).Methods("GET")
r.Handle("/api/v1/kolide/config", h.ModifyAppConfig).Methods("PATCH")
r.Handle("/api/v1/kolide/invites", h.CreateInvite).Methods("POST")
r.Handle("/api/v1/kolide/invites", h.ListInvites).Methods("GET")
r.Handle("/api/v1/kolide/invites/{id}", h.DeleteInvite).Methods("DELETE")
r.Handle("/api/v1/kolide/queries/{id}", h.GetQuery).Methods("GET")
r.Handle("/api/v1/kolide/queries", h.GetAllQueries).Methods("GET")
r.Handle("/api/v1/kolide/queries", h.CreateQuery).Methods("POST")
+12
View File
@@ -71,6 +71,18 @@ func TestAPIRoutes(t *testing.T) {
verb: "PATCH",
uri: "/api/v1/kolide/config",
},
{
verb: "GET",
uri: "/api/v1/kolide/invites",
},
{
verb: "POST",
uri: "/api/v1/kolide/invites",
},
{
verb: "DELETE",
uri: "/api/v1/kolide/invites/1",
},
{
verb: "GET",
uri: "/api/v1/kolide/queries/1",
+15 -44
View File
@@ -15,9 +15,7 @@ import (
kitlog "github.com/go-kit/kit/log"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/gorilla/mux"
"github.com/kolide/kolide-ose/server/config"
"github.com/kolide/kolide-ose/server/datastore"
"github.com/kolide/kolide-ose/server/kolide"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/context"
@@ -26,7 +24,7 @@ import (
func TestLogin(t *testing.T) {
ds, _ := datastore.New("inmem", "")
svc, _ := newTestService(ds)
createTestUsers(t, ds)
users := createTestUsers(t, ds)
logger := kitlog.NewLogfmtLogger(os.Stdout)
opts := []kithttp.ServerOption{
@@ -54,7 +52,7 @@ func TestLogin(t *testing.T) {
}{
{
username: "admin1",
password: *testUsers["admin1"].Password,
password: testUsers["admin1"].PlaintextPassword,
status: http.StatusOK,
},
{
@@ -70,21 +68,13 @@ func TestLogin(t *testing.T) {
}
for _, tt := range loginTests {
p, ok := testUsers[tt.username]
if !ok {
p = kolide.UserPayload{
Username: stringPtr(tt.username),
Password: stringPtr("foobar"),
Email: stringPtr("admin1@example.com"),
Admin: boolPtr(true),
}
var shouldBeAdmin bool
if u, ok := testUsers[tt.username]; ok {
shouldBeAdmin = u.IsAdmin
}
// test sessions
testUser, err := ds.User(tt.username)
if err != nil {
assert.Equal(t, datastore.ErrNotFound, err)
}
testUser, _ := users[tt.username]
params := loginRequest{
Username: tt.username,
@@ -117,7 +107,7 @@ func TestLogin(t *testing.T) {
continue // skip remaining tests
}
assert.Equal(t, falseIfNil(p.Admin), jsn.Admin)
assert.Equal(t, shouldBeAdmin, jsn.Admin)
// ensure that a session was created for our test user and stored
sessions, err := ds.FindAllSessionsForUser(testUser.ID)
@@ -144,36 +134,17 @@ func TestLogin(t *testing.T) {
}
}
func createTestUsers(t *testing.T, ds kolide.Datastore) {
svc := svcWithNoValidation(ds, kitlog.NewNopLogger())
ctx := context.Background()
for _, tt := range testUsers {
payload := kolide.UserPayload{
Username: tt.Username,
Password: tt.Password,
Email: tt.Email,
Admin: tt.Admin,
AdminForcedPasswordReset: tt.AdminForcedPasswordReset,
}
_, err := svc.NewUser(ctx, payload)
assert.Nil(t, err)
}
}
func svcWithNoValidation(ds kolide.Datastore, logger kitlog.Logger) kolide.Service {
var svc kolide.Service
svc = service{
ds: ds,
logger: logger,
config: config.TestConfig(),
}
return svc
}
// an io.ReadCloser for new request body
type nopCloser struct {
io.Reader
}
func (nopCloser) Close() error { return nil }
// helper to convert a bool pointer false
func falseIfNil(b *bool) bool {
if b == nil {
return false
}
return *b
}
+89
View File
@@ -0,0 +1,89 @@
package service
import (
"time"
"github.com/kolide/kolide-ose/server/contexts/viewer"
"github.com/kolide/kolide-ose/server/kolide"
"golang.org/x/net/context"
)
func (mw loggingMiddleware) InviteNewUser(ctx context.Context, payload kolide.InvitePayload) (*kolide.Invite, error) {
var (
invite *kolide.Invite
err error
)
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, errNoContext
}
defer func(begin time.Time) {
_ = mw.logger.Log(
"method", "InviteNewUser",
"created_by", vc.Username(),
"err", err,
"took", time.Since(begin),
)
}(time.Now())
invite, err = mw.Service.InviteNewUser(ctx, payload)
return invite, err
}
func (mw loggingMiddleware) DeleteInvite(ctx context.Context, id uint) error {
var (
err error
)
vc, ok := viewer.FromContext(ctx)
if !ok {
return errNoContext
}
defer func(begin time.Time) {
_ = mw.logger.Log(
"method", "DeleteInvite",
"deleted_by", vc.Username(),
"err", err,
"took", time.Since(begin),
)
}(time.Now())
err = mw.Service.DeleteInvite(ctx, id)
return err
}
func (mw loggingMiddleware) Invites(ctx context.Context) ([]*kolide.Invite, error) {
var (
invites []*kolide.Invite
err error
)
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, errNoContext
}
defer func(begin time.Time) {
_ = mw.logger.Log(
"method", "Invites",
"called_by", vc.Username(),
"err", err,
"took", time.Since(begin),
)
}(time.Now())
invites, err = mw.Service.Invites(ctx)
return invites, err
}
func (mw loggingMiddleware) VerifyInvite(ctx context.Context, email string, token string) error {
var (
err error
)
defer func(begin time.Time) {
_ = mw.logger.Log(
"method", "VerifyInvite",
"email", email,
"err", err,
"took", time.Since(begin),
)
}(time.Now())
err = mw.Service.VerifyInvite(ctx, email, token)
return err
}
+8 -7
View File
@@ -3,28 +3,29 @@ package service
import (
"time"
"github.com/kolide/kolide-ose/server/kolide"
"github.com/kolide/kolide-ose/server/contexts/viewer"
"github.com/kolide/kolide-ose/server/kolide"
"golang.org/x/net/context"
)
func (mw loggingMiddleware) NewUser(ctx context.Context, p kolide.UserPayload) (*kolide.User, error) {
var (
user *kolide.User
err error
username = "none"
user *kolide.User
err error
username = "none"
loggedInUser = "unauthenticated"
)
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, errNoContext
if ok {
loggedInUser = vc.Username()
}
defer func(begin time.Time) {
_ = mw.logger.Log(
"method", "NewUser",
"user", username,
"created_by", vc.Username(),
"created_by", loggedInUser,
"err", err,
"took", time.Since(begin),
)
+63
View File
@@ -0,0 +1,63 @@
package service
import (
"fmt"
"time"
"github.com/kolide/kolide-ose/server/kolide"
"golang.org/x/net/context"
)
func (mw metricsMiddleware) InviteNewUser(ctx context.Context, payload kolide.InvitePayload) (*kolide.Invite, error) {
var (
invite *kolide.Invite
err error
)
defer func(begin time.Time) {
lvs := []string{"method", "InviteNewUser", "error", fmt.Sprint(err != nil)}
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
}(time.Now())
invite, err = mw.Service.InviteNewUser(ctx, payload)
return invite, err
}
func (mw metricsMiddleware) DeleteInvite(ctx context.Context, id uint) error {
var (
err error
)
defer func(begin time.Time) {
lvs := []string{"method", "DeleteInvite", "error", fmt.Sprint(err != nil)}
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
}(time.Now())
err = mw.Service.DeleteInvite(ctx, id)
return err
}
func (mw metricsMiddleware) Invites(ctx context.Context) ([]*kolide.Invite, error) {
var (
invites []*kolide.Invite
err error
)
defer func(begin time.Time) {
lvs := []string{"method", "Invites", "error", fmt.Sprint(err != nil)}
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
}(time.Now())
invites, err = mw.Service.Invites(ctx)
return invites, err
}
func (mw metricsMiddleware) VerifyInvite(ctx context.Context, email string, token string) error {
var (
err error
)
defer func(begin time.Time) {
lvs := []string{"method", "VerifyInvite", "error", fmt.Sprint(err != nil)}
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
}(time.Now())
err = mw.Service.VerifyInvite(ctx, email, token)
return err
}
+1 -1
View File
@@ -1,4 +1,4 @@
// package service holds the implementation of the kolide service interface and the HTTP endpoints
// Package service holds the implementation of the kolide service interface and the HTTP endpoints
// for the API
package service
+93
View File
@@ -0,0 +1,93 @@
package service
import (
"errors"
jwt "github.com/dgrijalva/jwt-go"
"github.com/kolide/kolide-ose/server/datastore"
"github.com/kolide/kolide-ose/server/kolide"
"golang.org/x/net/context"
)
func (svc service) InviteNewUser(ctx context.Context, payload kolide.InvitePayload) (*kolide.Invite, error) {
// verify that the user with the given email does not already exist
_, err := svc.ds.UserByEmail(*payload.Email)
if err == nil {
return nil, newInvalidArgumentError("email", "a user with this account already exists")
}
if err != datastore.ErrNotFound {
return nil, err
}
// find the user who created the invite
inviter, err := svc.User(ctx, *payload.InvitedBy)
if err != nil {
return nil, err
}
token, err := jwt.New(jwt.SigningMethodHS256).SignedString([]byte(svc.config.App.TokenKey))
if err != nil {
return nil, err
}
invite := &kolide.Invite{
Email: *payload.Email,
Admin: *payload.Admin,
InvitedBy: inviter.ID,
CreatedAt: svc.clock.Now(),
Token: token,
}
if payload.Position != nil {
invite.Position = *payload.Position
}
if payload.Name != nil {
invite.Name = *payload.Name
}
invite, err = svc.ds.NewInvite(invite)
if err != nil {
return nil, err
}
inviteEmail := kolide.Email{
From: "no-reply@kolide.co",
To: []string{invite.Email},
Msg: invite,
}
err = svc.mailService.SendEmail(inviteEmail)
if err != nil {
return nil, err
}
return invite, nil
}
func (svc service) Invites(ctx context.Context) ([]*kolide.Invite, error) {
return svc.ds.Invites()
}
func (svc service) VerifyInvite(ctx context.Context, email, token string) error {
invite, err := svc.ds.InviteByEmail(email)
if err != nil {
return err
}
if invite.Token != token {
return newInvalidArgumentError("invite_token", "Invite Token does not match Email Address.")
}
expiresAt := invite.CreatedAt.Add(svc.config.App.InviteTokenValidityPeriod)
if svc.clock.Now().After(expiresAt) {
return errors.New("expired invite token")
}
return nil
}
func (svc service) DeleteInvite(ctx context.Context, id uint) error {
invite, err := svc.ds.Invite(id)
if err != nil {
return err
}
return svc.ds.DeleteInvite(invite)
}
+73
View File
@@ -0,0 +1,73 @@
package service
import (
"context"
"testing"
"github.com/WatchBeam/clock"
"github.com/kolide/kolide-ose/server/config"
"github.com/kolide/kolide-ose/server/datastore"
"github.com/kolide/kolide-ose/server/kolide"
"github.com/stretchr/testify/assert"
)
func TestInviteNewUser(t *testing.T) {
ds, err := datastore.New("inmem", "")
createTestUsers(t, ds)
assert.Nil(t, err)
nosuchAdminID := uint(999)
adminID := uint(1)
mailer := &mockMailService{SendEmailFn: func(e kolide.Email) error { return nil }}
svc := validationMiddleware{service{
ds: ds,
config: config.TestConfig(),
mailService: mailer,
clock: clock.NewMockClock(),
}}
var inviteTests = []struct {
payload kolide.InvitePayload
wantErr error
}{
{
wantErr: &invalidArgumentError{
{name: "email", reason: "missing required argument"},
{name: "invited_by", reason: "missing required argument"},
{name: "admin", reason: "missing required argument"},
},
},
{
payload: kolide.InvitePayload{
Email: stringPtr("nosuchuser@example.com"),
InvitedBy: &nosuchAdminID,
Admin: boolPtr(false),
},
wantErr: datastore.ErrNotFound,
},
{
payload: kolide.InvitePayload{
Email: stringPtr("admin1@example.com"),
InvitedBy: &adminID,
Admin: boolPtr(false),
},
wantErr: &invalidArgumentError{
{name: "email", reason: "a user with this account already exists"}},
},
{
payload: kolide.InvitePayload{
Email: stringPtr("nosuchuser@example.com"),
InvitedBy: &adminID,
Admin: boolPtr(false),
},
},
}
for _, tt := range inviteTests {
invite, err := svc.InviteNewUser(context.Background(), tt.payload)
assert.Equal(t, err, tt.wantErr)
if err != nil {
continue
}
assert.Equal(t, *tt.payload.InvitedBy, invite.InvitedBy)
}
}
+14 -25
View File
@@ -14,25 +14,33 @@ import (
const bcryptCost = 6
func TestAuthenticate(t *testing.T) {
svc, payload, user := setupLoginTests(t)
ds, err := datastore.New("gorm-sqlite3", ":memory:")
require.Nil(t, err)
svc, err := newTestService(ds)
require.Nil(t, err)
users := createTestUsers(t, ds)
var loginTests = []struct {
username string
password string
user kolide.User
wantErr error
}{
{
username: *payload.Username,
password: *payload.Password,
user: users["admin1"],
username: testUsers["admin1"].Username,
password: testUsers["admin1"].PlaintextPassword,
},
{
username: *payload.Email,
password: *payload.Password,
user: users["user1"],
username: testUsers["user1"].Email,
password: testUsers["user1"].PlaintextPassword,
},
}
for _, tt := range loginTests {
t.Run(tt.username, func(st *testing.T) {
svc, _, user = setupLoginTests(t)
user := tt.user
ctx := context.Background()
loggedIn, token, err := svc.Login(ctx, tt.username, tt.password)
require.Nil(st, err, "login unsuccesful")
@@ -50,22 +58,3 @@ func TestAuthenticate(t *testing.T) {
}
}
func setupLoginTests(t *testing.T) (kolide.Service, kolide.UserPayload, kolide.User) {
ds, err := datastore.New("gorm-sqlite3", ":memory:")
assert.Nil(t, err)
svc, err := newTestService(ds)
assert.Nil(t, err)
payload := kolide.UserPayload{
Username: stringPtr("foo"),
Password: stringPtr("bar"),
Email: stringPtr("foo@kolide.co"),
Admin: boolPtr(false),
}
ctx := context.Background()
user, err := svc.NewUser(ctx, payload)
assert.Nil(t, err)
assert.NotZero(t, user.ID)
return svc, payload, *user
}
+18 -61
View File
@@ -3,17 +3,23 @@ package service
import (
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"github.com/kolide/kolide-ose/server/kolide"
"github.com/kolide/kolide-ose/server/contexts/viewer"
"golang.org/x/crypto/bcrypt"
"github.com/kolide/kolide-ose/server/kolide"
"golang.org/x/net/context"
)
func (svc service) NewUser(ctx context.Context, p kolide.UserPayload) (*kolide.User, error) {
user, err := userFromPayload(p, svc.config.Auth.SaltKeySize, svc.config.Auth.BcryptCost)
err := svc.VerifyInvite(ctx, *p.Email, *p.InviteToken)
if err != nil {
return nil, err
}
invite, err := svc.ds.InviteByEmail(*p.Email)
if err != nil {
return nil, err
}
user, err := p.User(svc.config.Auth.SaltKeySize, svc.config.Auth.BcryptCost)
if err != nil {
return nil, err
}
@@ -21,6 +27,10 @@ func (svc service) NewUser(ctx context.Context, p kolide.UserPayload) (*kolide.U
if err != nil {
return nil, err
}
err = svc.ds.DeleteInvite(invite)
if err != nil {
return nil, err
}
return user, nil
}
@@ -68,7 +78,7 @@ func (svc service) ModifyUser(ctx context.Context, userID uint, p kolide.UserPay
}
if p.Password != nil {
hashed, salt, err := hashPassword(
err := user.SetPassword(
*p.Password,
svc.config.Auth.SaltKeySize,
svc.config.Auth.BcryptCost,
@@ -76,8 +86,6 @@ func (svc service) ModifyUser(ctx context.Context, userID uint, p kolide.UserPay
if err != nil {
return nil, err
}
user.Password = hashed
user.Salt = salt
user.AdminForcedPasswordReset = false
}
@@ -119,12 +127,10 @@ func (svc service) ResetPassword(ctx context.Context, token, password string) er
return err
}
hashed, salt, err := hashPassword(password, svc.config.Auth.SaltKeySize, svc.config.Auth.BcryptCost)
err = user.SetPassword(password, svc.config.Auth.SaltKeySize, svc.config.Auth.BcryptCost)
if err != nil {
return err
}
user.Salt = salt
user.Password = hashed
if err := svc.saveUser(user); err != nil {
return err
}
@@ -160,7 +166,8 @@ func (svc service) RequestPasswordReset(ctx context.Context, email string) error
}
}
token, err := generateRandomText(svc.config.SMTP.TokenKeySize)
// TODO: change this to jwt key
token, err := generateRandomText(svc.config.App.TokenKeySize)
if err != nil {
return err
}
@@ -201,48 +208,6 @@ func (svc service) saveUser(user *kolide.User) error {
return svc.ds.SaveUser(user)
}
func userFromPayload(p kolide.UserPayload, keySize, cost int) (*kolide.User, error) {
hashed, salt, err := hashPassword(*p.Password, keySize, cost)
if err != nil {
return nil, err
}
user := &kolide.User{
Username: *p.Username,
Email: *p.Email,
Admin: falseIfNil(p.Admin),
AdminForcedPasswordReset: falseIfNil(p.AdminForcedPasswordReset),
Salt: salt,
Enabled: true,
Password: hashed,
}
// add optional fields
if p.Name != nil {
user.Name = *p.Name
}
if p.Position != nil {
user.Position = *p.Position
}
return user, nil
}
func hashPassword(plaintext string, keySize, cost int) ([]byte, string, error) {
salt, err := generateRandomText(keySize)
if err != nil {
return nil, "", err
}
withSalt := []byte(fmt.Sprintf("%s%s", plaintext, salt))
hashed, err := bcrypt.GenerateFromPassword(withSalt, cost)
if err != nil {
return nil, "", err
}
return hashed, salt, nil
}
// generateRandomText return a string generated by filling in keySize bytes with
// random data and then base64 encoding those bytes
func generateRandomText(keySize int) (string, error) {
@@ -253,11 +218,3 @@ func generateRandomText(keySize int) (string, error) {
}
return base64.StdEncoding.EncodeToString(key), nil
}
// helper to convert a bool pointer false
func falseIfNil(b *bool) bool {
if b == nil {
return false
}
return *b
}
+69 -56
View File
@@ -6,10 +6,11 @@ import (
"testing"
"time"
"github.com/WatchBeam/clock"
"github.com/kolide/kolide-ose/server/config"
"github.com/kolide/kolide-ose/server/contexts/viewer"
"github.com/kolide/kolide-ose/server/datastore"
"github.com/kolide/kolide-ose/server/kolide"
"github.com/kolide/kolide-ose/server/contexts/viewer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/context"
@@ -114,6 +115,7 @@ func TestRequestPasswordReset(t *testing.T) {
func TestCreateUser(t *testing.T) {
ds, _ := datastore.New("inmem", "")
svc, _ := newTestService(ds)
invites := setupInvites(t, ds, []string{"admin2@example.com"})
ctx := context.Background()
var createUserTests = []struct {
@@ -122,48 +124,70 @@ func TestCreateUser(t *testing.T) {
Email *string
NeedsPasswordReset *bool
Admin *bool
InviteToken *string
wantErr error
}{
{
Username: stringPtr("admin1"),
Username: stringPtr("admin2"),
Password: stringPtr("foobar"),
InviteToken: &invites["admin2@example.com"].Token,
wantErr: &invalidArgumentError{invalidArgument{name: "email", reason: "missing required argument"}},
},
{
Username: stringPtr("admin2"),
Password: stringPtr("foobar"),
wantErr: invalidArgumentError{invalidArgument{name: "email", reason: "missing required argument"}},
Email: stringPtr("admin2@example.com"),
wantErr: &invalidArgumentError{invalidArgument{name: "invite_token", reason: "missing required argument"}},
},
{
Username: stringPtr("admin1"),
Username: stringPtr("admin2"),
Password: stringPtr("foobar"),
Email: stringPtr("admin1@example.com"),
Email: stringPtr("admin2@example.com"),
NeedsPasswordReset: boolPtr(true),
Admin: boolPtr(false),
InviteToken: &invites["admin2@example.com"].Token,
},
{ // should return ErrNotFound because the invite is deleted
// after a user signs up
Username: stringPtr("admin2"),
Password: stringPtr("foobar"),
Email: stringPtr("admin2@example.com"),
NeedsPasswordReset: boolPtr(true),
Admin: boolPtr(false),
InviteToken: &invites["admin2@example.com"].Token,
wantErr: datastore.ErrNotFound,
},
{
Username: stringPtr("admin1"),
Username: stringPtr("admin3"),
Password: stringPtr("foobar"),
Email: stringPtr("admin1@example.com"),
Email: &invites["expired"].Email,
NeedsPasswordReset: boolPtr(true),
Admin: boolPtr(false),
wantErr: datastore.ErrExists,
InviteToken: &invites["expired"].Token,
wantErr: errors.New("expired invite token"),
},
{
Username: stringPtr("@admin1"),
Username: stringPtr("@admin2"),
Password: stringPtr("foobar"),
Email: stringPtr("admin1@example.com"),
Email: stringPtr("admin2@example.com"),
NeedsPasswordReset: boolPtr(true),
Admin: boolPtr(false),
wantErr: invalidArgumentError{invalidArgument{name: "username", reason: "'@' character not allowed in usernames"}},
InviteToken: &invites["admin2@example.com"].Token,
wantErr: &invalidArgumentError{invalidArgument{name: "username", reason: "'@' character not allowed in usernames"}},
},
}
for _, tt := range createUserTests {
for i, tt := range createUserTests {
payload := kolide.UserPayload{
Username: tt.Username,
Password: tt.Password,
Email: tt.Email,
Admin: tt.Admin,
Username: tt.Username,
Password: tt.Password,
Email: tt.Email,
Admin: tt.Admin,
InviteToken: tt.InviteToken,
AdminForcedPasswordReset: tt.NeedsPasswordReset,
}
user, err := svc.NewUser(ctx, payload)
require.Equal(t, tt.wantErr, err)
require.Equal(t, tt.wantErr, err, strconv.Itoa(i))
if err != nil {
// skip rest of the test if error is not nil
continue
@@ -183,6 +207,32 @@ func TestCreateUser(t *testing.T) {
}
}
func setupInvites(t *testing.T, ds kolide.Datastore, emails []string) map[string]*kolide.Invite {
invites := make(map[string]*kolide.Invite)
users := createTestUsers(t, ds)
mockClock := clock.NewMockClock()
for _, e := range emails {
invite, err := ds.NewInvite(&kolide.Invite{
InvitedBy: users["admin1"].ID,
Token: e,
Email: e,
CreatedAt: mockClock.Now(),
})
require.Nil(t, err)
invites[e] = invite
}
// add an expired invitation
invite, err := ds.NewInvite(&kolide.Invite{
InvitedBy: users["admin1"].ID,
Token: "expired",
Email: "expiredinvite@gmail.com",
CreatedAt: mockClock.Now().AddDate(-1, 0, 0),
})
require.Nil(t, err)
invites["expired"] = invite
return invites
}
func TestChangeUserPassword(t *testing.T) {
ds, _ := datastore.New("inmem", "")
svc, _ := newTestService(ds)
@@ -203,11 +253,11 @@ func TestChangeUserPassword(t *testing.T) {
},
{ // missing token
newPassword: "123cat!",
wantErr: invalidArgumentError{invalidArgument{name: "token", reason: "cannot be empty field"}},
wantErr: &invalidArgumentError{invalidArgument{name: "token", reason: "cannot be empty field"}},
},
{ // missing password
token: "abcd",
wantErr: invalidArgumentError{invalidArgument{name: "new_password", reason: "cannot be empty field"}},
wantErr: &invalidArgumentError{invalidArgument{name: "new_password", reason: "cannot be empty field"}},
},
}
@@ -227,40 +277,3 @@ func TestChangeUserPassword(t *testing.T) {
assert.Equal(t, tt.wantErr, serr, strconv.Itoa(i))
}
}
type mockMailService struct {
SendEmailFn func(e kolide.Email) error
Invoked bool
}
func (svc *mockMailService) SendEmail(e kolide.Email) error {
svc.Invoked = true
return svc.SendEmailFn(e)
}
var testUsers = map[string]kolide.UserPayload{
"admin1": {
Username: stringPtr("admin1"),
Password: stringPtr("foobar"),
Email: stringPtr("admin1@example.com"),
Admin: boolPtr(true),
},
"user1": {
Username: stringPtr("user1"),
Password: stringPtr("foobar"),
Email: stringPtr("user1@example.com"),
},
"user2": {
Username: stringPtr("user2"),
Password: stringPtr("bazfoo"),
Email: stringPtr("user2@example.com"),
},
}
func stringPtr(s string) *string {
return &s
}
func boolPtr(b bool) *bool {
return &b
}
-16
View File
@@ -1,16 +0,0 @@
package service
import (
"github.com/WatchBeam/clock"
kitlog "github.com/go-kit/kit/log"
"github.com/kolide/kolide-ose/server/config"
"github.com/kolide/kolide-ose/server/kolide"
)
func newTestService(ds kolide.Datastore) (kolide.Service, error) {
return NewService(ds, kitlog.NewNopLogger(), config.TestConfig(), nil, clock.C)
}
func newTestServiceWithClock(ds kolide.Datastore, c clock.Clock) (kolide.Service, error) {
return NewService(ds, kitlog.NewNopLogger(), config.TestConfig(), nil, c)
}
+30
View File
@@ -0,0 +1,30 @@
package service
import (
"encoding/json"
"net/http"
"golang.org/x/net/context"
)
func decodeCreateInviteRequest(ctx context.Context, r *http.Request) (interface{}, error) {
var req createInviteRequest
if err := json.NewDecoder(r.Body).Decode(&req.payload); err != nil {
return nil, err
}
return req, nil
}
func decodeDeleteInviteRequest(ctx context.Context, r *http.Request) (interface{}, error) {
id, err := idFromRequest(r, "id")
if err != nil {
return nil, err
}
var req deleteInviteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, err
}
req.ID = id
return req, nil
}
+37
View File
@@ -0,0 +1,37 @@
package service
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
)
func TestDecodeCreateInviteRequest(t *testing.T) {
router := mux.NewRouter()
router.HandleFunc("/api/v1/kolide/invites", func(writer http.ResponseWriter, request *http.Request) {
r, err := decodeCreateInviteRequest(context.Background(), request)
assert.Nil(t, err)
params := r.(createInviteRequest)
assert.Equal(t, "foo", *params.payload.Name)
assert.Equal(t, "foo@kolide.co", *params.payload.Email)
assert.Equal(t, uint(1), *params.payload.InvitedBy)
}).Methods("POST")
var body bytes.Buffer
body.Write([]byte(`{
"name": "foo",
"email": "foo@kolide.co",
"invited_by": 1
}`))
router.ServeHTTP(
httptest.NewRecorder(),
httptest.NewRequest("POST", "/api/v1/kolide/invites", &body),
)
}
+89
View File
@@ -0,0 +1,89 @@
package service
import (
"testing"
"github.com/WatchBeam/clock"
kitlog "github.com/go-kit/kit/log"
"github.com/kolide/kolide-ose/server/config"
"github.com/kolide/kolide-ose/server/kolide"
"github.com/stretchr/testify/require"
)
func newTestService(ds kolide.Datastore) (kolide.Service, error) {
return NewService(ds, kitlog.NewNopLogger(), config.TestConfig(), nil, clock.C)
}
func newTestServiceWithClock(ds kolide.Datastore, c clock.Clock) (kolide.Service, error) {
return NewService(ds, kitlog.NewNopLogger(), config.TestConfig(), nil, c)
}
func createTestUsers(t *testing.T, ds kolide.Datastore) map[string]kolide.User {
users := make(map[string]kolide.User)
for _, u := range testUsers {
user := &kolide.User{
Username: u.Username,
Email: u.Email,
Admin: u.IsAdmin,
Enabled: u.Enabled,
}
err := user.SetPassword(u.PlaintextPassword, 10, 10)
require.Nil(t, err)
user, err = ds.NewUser(user)
require.Nil(t, err)
users[user.Username] = *user
}
return users
}
var testUsers = map[string]struct {
Username string
Email string
PlaintextPassword string
IsAdmin bool
Enabled bool
}{
"admin1": {
Username: "admin1",
PlaintextPassword: "foobar",
Email: "admin1@example.com",
IsAdmin: true,
Enabled: true,
},
"user1": {
Username: "user1",
PlaintextPassword: "foobar",
Email: "user1@example.com",
Enabled: true,
},
"user2": {
Username: "user2",
PlaintextPassword: "bazfoo",
Email: "user2@example.com",
Enabled: true,
},
"disabled1": {
Username: "disabled1",
PlaintextPassword: "bazfoo",
Email: "disabled1@example.com",
Enabled: false,
},
}
type mockMailService struct {
SendEmailFn func(e kolide.Email) error
Invoked bool
}
func (svc *mockMailService) SendEmail(e kolide.Email) error {
svc.Invoked = true
return svc.SendEmailFn(e)
}
func stringPtr(s string) *string {
return &s
}
func boolPtr(b bool) *bool {
return &b
}
+23
View File
@@ -0,0 +1,23 @@
package service
import (
"github.com/kolide/kolide-ose/server/kolide"
"golang.org/x/net/context"
)
func (mw validationMiddleware) InviteNewUser(ctx context.Context, payload kolide.InvitePayload) (*kolide.Invite, error) {
invalid := &invalidArgumentError{}
if payload.Email == nil {
invalid.Append("email", "missing required argument")
}
if payload.InvitedBy == nil {
invalid.Append("invited_by", "missing required argument")
}
if payload.Admin == nil {
invalid.Append("admin", "missing required argument")
}
if invalid.HasErrors() {
return nil, invalid
}
return mw.Service.InviteNewUser(ctx, payload)
}
+37 -12
View File
@@ -13,37 +13,40 @@ type validationMiddleware struct {
}
func (mw validationMiddleware) NewUser(ctx context.Context, p kolide.UserPayload) (*kolide.User, error) {
var invalid []invalidArgument
invalid := &invalidArgumentError{}
if p.Username == nil {
invalid = append(invalid, invalidArgument{name: "username", reason: "missing required argument"})
invalid.Append("username", "missing required argument")
}
if p.Username != nil {
if strings.Contains(*p.Username, "@") {
invalid = append(invalid, invalidArgument{name: "username", reason: "'@' character not allowed in usernames"})
invalid.Append("username", "'@' character not allowed in usernames")
}
}
if p.Password == nil {
invalid = append(invalid, invalidArgument{name: "password", reason: "missing required argument"})
invalid.Append("password", "missing required argument")
}
if p.Email == nil {
invalid = append(invalid, invalidArgument{name: "email", reason: "missing required argument"})
invalid.Append("email", "missing required argument")
}
if len(invalid) != 0 {
return nil, invalidArgumentError(invalid)
if p.InviteToken == nil {
invalid.Append("invite_token", "missing required argument")
}
if invalid.HasErrors() {
return nil, invalid
}
return mw.Service.NewUser(ctx, p)
}
func (mw validationMiddleware) ResetPassword(ctx context.Context, token, password string) error {
var invalid []invalidArgument
invalid := &invalidArgumentError{}
if token == "" {
invalid = append(invalid, invalidArgument{name: "token", reason: "cannot be empty field"})
invalid.Append("token", "cannot be empty field")
}
if password == "" {
invalid = append(invalid, invalidArgument{name: "new_password", reason: "cannot be empty field"})
invalid.Append("new_password", "cannot be empty field")
}
if len(invalid) != 0 {
return invalidArgumentError(invalid)
if invalid.HasErrors() {
return invalid
}
return mw.Service.ResetPassword(ctx, token, password)
}
@@ -54,6 +57,28 @@ type invalidArgument struct {
reason string
}
// newInvalidArgumentError returns a invalidArgumentError with at least
// one error.
func newInvalidArgumentError(name, reason string) *invalidArgumentError {
var invalid invalidArgumentError
invalid = append(invalid, invalidArgument{
name: name,
reason: reason,
})
return &invalid
}
func (e *invalidArgumentError) Append(name, reason string) {
*e = append(*e, invalidArgument{
name: name,
reason: reason,
})
}
func (e *invalidArgumentError) HasErrors() bool {
return len(*e) != 0
}
// invalidArgumentError is returned when one or more arguments are invalid.
func (e invalidArgumentError) Error() string {
switch len(e) {