From 6fb96d98f7d7f2aa2e1ac8cf671d04217ecacb13 Mon Sep 17 00:00:00 2001 From: Victor Vrantchan Date: Wed, 28 Sep 2016 22:44:05 -0400 Subject: [PATCH] Adds endpoints to invite new users to the application. (#235) User service checks that tokens are valid on new user signups. Closes #230 --- cli/serve.go | 19 ++-- server/config/config.go | 21 +++- server/datastore/datastore.go | 1 + server/datastore/gorm.go | 1 + server/datastore/gorm_invite.go | 48 ++++++++ server/datastore/gorm_invite_test.go | 30 +++++ server/datastore/inmem.go | 1 + server/datastore/inmem_invite.go | 79 +++++++++++++ server/datastore/inmem_users.go | 1 - server/kolide/datastore.go | 1 + server/kolide/invite.go | 89 +++++++++++++++ server/kolide/service.go | 1 + server/kolide/users.go | 64 +++++++++++ server/service/endpoint_invites.go | 97 ++++++++++++++++ server/service/endpoint_middleware_test.go | 2 +- server/service/handler.go | 17 ++- server/service/handler_test.go | 12 ++ server/service/http_auth_test.go | 59 +++------- server/service/logging_invites.go | 89 +++++++++++++++ server/service/logging_users.go | 15 +-- server/service/metrics_invites.go | 63 +++++++++++ server/service/service.go | 2 +- server/service/service_invites.go | 93 +++++++++++++++ server/service/service_invites_test.go | 73 ++++++++++++ server/service/service_sessions_test.go | 39 +++---- server/service/service_users.go | 79 +++---------- server/service/service_users_test.go | 125 ++++++++++++--------- server/service/test_util.go | 16 --- server/service/transport_invites.go | 30 +++++ server/service/transport_invites_test.go | 37 ++++++ server/service/util_test.go | 89 +++++++++++++++ server/service/validation_invites.go | 23 ++++ server/service/validation_users.go | 49 ++++++-- 33 files changed, 1124 insertions(+), 241 deletions(-) create mode 100644 server/datastore/gorm_invite.go create mode 100644 server/datastore/gorm_invite_test.go create mode 100644 server/datastore/inmem_invite.go create mode 100644 server/kolide/invite.go create mode 100644 server/service/endpoint_invites.go create mode 100644 server/service/logging_invites.go create mode 100644 server/service/metrics_invites.go create mode 100644 server/service/service_invites.go create mode 100644 server/service/service_invites_test.go delete mode 100644 server/service/test_util.go create mode 100644 server/service/transport_invites.go create mode 100644 server/service/transport_invites_test.go create mode 100644 server/service/util_test.go create mode 100644 server/service/validation_invites.go diff --git a/cli/serve.go b/cli/serve.go index 345e7146d8..9fa0e68d61 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -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") } diff --git a/server/config/config.go b/server/config/config.go index 2213e588cd..eb7d4b2c76 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -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{}, } } diff --git a/server/datastore/datastore.go b/server/datastore/datastore.go index 2ff0a3e3f3..a30cf49313 100644 --- a/server/datastore/datastore.go +++ b/server/datastore/datastore.go @@ -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: diff --git a/server/datastore/gorm.go b/server/datastore/gorm.go index af0f2f14f3..fe2188d31e 100644 --- a/server/datastore/gorm.go +++ b/server/datastore/gorm.go @@ -35,6 +35,7 @@ var tables = [...]interface{}{ &kolide.Query{}, &kolide.DistributedQueryExecution{}, &kolide.OrgInfo{}, + &kolide.Invite{}, } type gormDB struct { diff --git a/server/datastore/gorm_invite.go b/server/datastore/gorm_invite.go new file mode 100644 index 0000000000..1830d319cd --- /dev/null +++ b/server/datastore/gorm_invite.go @@ -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 +} diff --git a/server/datastore/gorm_invite_test.go b/server/datastore/gorm_invite_test.go new file mode 100644 index 0000000000..de527cca11 --- /dev/null +++ b/server/datastore/gorm_invite_test.go @@ -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) +} diff --git a/server/datastore/inmem.go b/server/datastore/inmem.go index acac8f9eb8..dcb70a54d7 100644 --- a/server/datastore/inmem.go +++ b/server/datastore/inmem.go @@ -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 } diff --git a/server/datastore/inmem_invite.go b/server/datastore/inmem_invite.go new file mode 100644 index 0000000000..bd99fe1f79 --- /dev/null +++ b/server/datastore/inmem_invite.go @@ -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 +} diff --git a/server/datastore/inmem_users.go b/server/datastore/inmem_users.go index e588432621..cb9844e099 100644 --- a/server/datastore/inmem_users.go +++ b/server/datastore/inmem_users.go @@ -41,7 +41,6 @@ func (orm *inmem) Users() ([]*kolide.User, error) { } return users, nil - } func (orm *inmem) UserByEmail(email string) (*kolide.User, error) { diff --git a/server/kolide/datastore.go b/server/kolide/datastore.go index 96b91a7a33..382d6eb274 100644 --- a/server/kolide/datastore.go +++ b/server/kolide/datastore.go @@ -10,6 +10,7 @@ type Datastore interface { PasswordResetStore SessionStore AppConfigStore + InviteStore Name() string Drop() error Migrate() error diff --git a/server/kolide/invite.go b/server/kolide/invite.go new file mode 100644 index 0000000000..e053cf94c4 --- /dev/null +++ b/server/kolide/invite.go @@ -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 +} diff --git a/server/kolide/service.go b/server/kolide/service.go index d8c9c2ff02..237de77444 100644 --- a/server/kolide/service.go +++ b/server/kolide/service.go @@ -9,4 +9,5 @@ type Service interface { OsqueryService HostService AppConfigService + InviteService } diff --git a/server/kolide/users.go b/server/kolide/users.go index b231579235..e7efb41b0c 100644 --- a/server/kolide/users.go +++ b/server/kolide/users.go @@ -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 +} diff --git a/server/service/endpoint_invites.go b/server/service/endpoint_invites.go new file mode 100644 index 0000000000..9d267868b9 --- /dev/null +++ b/server/service/endpoint_invites.go @@ -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 + } +} diff --git a/server/service/endpoint_middleware_test.go b/server/service/endpoint_middleware_test.go index 9d3314e80b..64f5fde262 100644 --- a/server/service/endpoint_middleware_test.go +++ b/server/service/endpoint_middleware_test.go @@ -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" ) diff --git a/server/service/handler.go b/server/service/handler.go index b52624314f..d221c9cb38 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -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") diff --git a/server/service/handler_test.go b/server/service/handler_test.go index 923b2c3c09..45cc7a266f 100644 --- a/server/service/handler_test.go +++ b/server/service/handler_test.go @@ -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", diff --git a/server/service/http_auth_test.go b/server/service/http_auth_test.go index bea994b900..ec54d026f7 100644 --- a/server/service/http_auth_test.go +++ b/server/service/http_auth_test.go @@ -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 +} diff --git a/server/service/logging_invites.go b/server/service/logging_invites.go new file mode 100644 index 0000000000..a355bdf187 --- /dev/null +++ b/server/service/logging_invites.go @@ -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 +} diff --git a/server/service/logging_users.go b/server/service/logging_users.go index 142a4856ca..6327f04863 100644 --- a/server/service/logging_users.go +++ b/server/service/logging_users.go @@ -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), ) diff --git a/server/service/metrics_invites.go b/server/service/metrics_invites.go new file mode 100644 index 0000000000..2b6fef4ae4 --- /dev/null +++ b/server/service/metrics_invites.go @@ -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 +} diff --git a/server/service/service.go b/server/service/service.go index a68334ca7e..d339bb2b8f 100644 --- a/server/service/service.go +++ b/server/service/service.go @@ -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 diff --git a/server/service/service_invites.go b/server/service/service_invites.go new file mode 100644 index 0000000000..932fef3577 --- /dev/null +++ b/server/service/service_invites.go @@ -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) +} diff --git a/server/service/service_invites_test.go b/server/service/service_invites_test.go new file mode 100644 index 0000000000..4ff1f26abb --- /dev/null +++ b/server/service/service_invites_test.go @@ -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) + } +} diff --git a/server/service/service_sessions_test.go b/server/service/service_sessions_test.go index d16b78d913..d20fc4c901 100644 --- a/server/service/service_sessions_test.go +++ b/server/service/service_sessions_test.go @@ -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 -} diff --git a/server/service/service_users.go b/server/service/service_users.go index a8f5f6adea..73a4f26292 100644 --- a/server/service/service_users.go +++ b/server/service/service_users.go @@ -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 -} diff --git a/server/service/service_users_test.go b/server/service/service_users_test.go index 223de0978c..d5881f9487 100644 --- a/server/service/service_users_test.go +++ b/server/service/service_users_test.go @@ -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 -} diff --git a/server/service/test_util.go b/server/service/test_util.go deleted file mode 100644 index e94d89b88b..0000000000 --- a/server/service/test_util.go +++ /dev/null @@ -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) -} diff --git a/server/service/transport_invites.go b/server/service/transport_invites.go new file mode 100644 index 0000000000..f776ed7b30 --- /dev/null +++ b/server/service/transport_invites.go @@ -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 +} diff --git a/server/service/transport_invites_test.go b/server/service/transport_invites_test.go new file mode 100644 index 0000000000..5b291c90c1 --- /dev/null +++ b/server/service/transport_invites_test.go @@ -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), + ) +} diff --git a/server/service/util_test.go b/server/service/util_test.go new file mode 100644 index 0000000000..f65de24b80 --- /dev/null +++ b/server/service/util_test.go @@ -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 +} diff --git a/server/service/validation_invites.go b/server/service/validation_invites.go new file mode 100644 index 0000000000..ff11c8d7c2 --- /dev/null +++ b/server/service/validation_invites.go @@ -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) +} diff --git a/server/service/validation_users.go b/server/service/validation_users.go index 228445f6db..4ceeb89f8f 100644 --- a/server/service/validation_users.go +++ b/server/service/validation_users.go @@ -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) {