diff --git a/cmd/blas/main.go b/cmd/blas/main.go index 7691964..8b398b7 100644 --- a/cmd/blas/main.go +++ b/cmd/blas/main.go @@ -25,6 +25,6 @@ func main() { err = rootCmd.Execute() if err != nil { - panic(err) + log.Fatal(err) } } diff --git a/go.mod b/go.mod index b471ed2..4f7a4f5 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,8 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.1 // indirect - golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect - golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect - golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/crypto v0.1.0 // indirect + golang.org/x/net v0.1.0 // indirect + golang.org/x/sys v0.1.0 // indirect + golang.org/x/text v0.4.0 // indirect ) diff --git a/go.sum b/go.sum index 6caacae..87a0816 100644 --- a/go.sum +++ b/go.sum @@ -30,14 +30,22 @@ github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52 github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/pkg/auth/authenticator.go b/pkg/auth/authenticator.go index 46edf2e..346d8f6 100644 --- a/pkg/auth/authenticator.go +++ b/pkg/auth/authenticator.go @@ -1,26 +1,57 @@ package auth import ( + "errors" "io" "net/http" "github.com/labstack/echo/v4" "dynatron.me/x/blasphem/pkg/frontend" + "dynatron.me/x/blasphem/pkg/storage" +) + +var ( + ErrInvalidAuth = errors.New("invalid auth") + ErrInvalidHandler = errors.New("no such handler") ) type Authenticator struct { - Flows FlowStore + Flows FlowStore + Providers map[string]AuthProvider } -func (a *Authenticator) InitAuth() { +func (a *Authenticator) Provider(name string) AuthProvider { + p, ok := a.Providers[name] + if !ok { + return nil + } + + return p +} + +func (a *Authenticator) InitAuth(s storage.Store) error { a.Flows = make(FlowStore) + hap, err := NewHAProvider(s) + if err != nil { + return err + } + + // XXX: yuck + a.Providers = map[string]AuthProvider{ + hap.ProviderType(): hap, + } + + return nil } type AuthProvider interface { ProviderName() string ProviderID() *string ProviderType() string + ProviderBase() AuthProviderBase + FlowSchema() []FlowSchemaItem + ValidateCreds(reqMap map[string]interface{}) bool } type AuthProviderBase struct { @@ -29,29 +60,17 @@ type AuthProviderBase struct { Type string `json:"type"` } -func (bp *AuthProviderBase) ProviderName() string { return bp.Name } -func (bp *AuthProviderBase) ProviderID() *string { return bp.ID } -func (bp *AuthProviderBase) ProviderType() string { return bp.Type } - -type LocalProvider struct { - AuthProviderBase -} +func (bp *AuthProviderBase) ProviderName() string { return bp.Name } +func (bp *AuthProviderBase) ProviderID() *string { return bp.ID } +func (bp *AuthProviderBase) ProviderType() string { return bp.Type } +func (bp *AuthProviderBase) ProviderBase() AuthProviderBase { return *bp } var HomeAssistant = "homeassistant" -func hassProvider() *LocalProvider { - return &LocalProvider{ - AuthProviderBase: AuthProviderBase{ - Name: "Home Assistant Local", - Type: HomeAssistant, - }, - } -} - // TODO: make this configurable -func (s *Authenticator) ProvidersHandler(c echo.Context) error { - providers := []AuthProvider{ - hassProvider(), +func (a *Authenticator) ProvidersHandler(c echo.Context) error { + providers := []AuthProviderBase{ + a.Provider(HomeAssistant).ProviderBase(), } return c.JSON(http.StatusOK, providers) @@ -71,3 +90,29 @@ func (s *Authenticator) AuthorizeHandler(c echo.Context) error { return c.HTML(http.StatusOK, string(b)) } + +func (a *Authenticator) Check(f *Flow, rm map[string]interface{}) error { + cID, hasCID := rm["client_id"] + if !hasCID || cID != f.request.ClientID { + return ErrInvalidAuth + } + + for _, h := range f.Handler { + if h == nil { + return ErrInvalidHandler + } + + p := a.Provider(*h) + if p == nil { + return ErrInvalidAuth + } + + success := p.ValidateCreds(rm) + + if success { + return nil + } + } + + return ErrInvalidAuth +} diff --git a/pkg/auth/flow.go b/pkg/auth/flow.go index 70163c9..6c76690 100644 --- a/pkg/auth/flow.go +++ b/pkg/auth/flow.go @@ -43,7 +43,7 @@ type Flow struct { Handler []*string `json:"handler"` StepID Step `json:"step_id"` Schema []FlowSchemaItem `json:"data_schema"` - Errors []string `json:"errors"` + Errors interface{} `json:"errors"` DescPlace *string `json:"description_placeholders"` LastStep *string `json:"last_step"` @@ -89,22 +89,28 @@ func (fs FlowStore) Get(id FlowID) *Flow { } func (a *Authenticator) NewFlow(r *FlowRequest) *Flow { + var sch []FlowSchemaItem + + for _, h := range r.Handler { + if h == nil { + break + } + + if hand := a.Provider(*h); hand != nil { + sch = hand.FlowSchema() + break + } + } + + if sch == nil { + return nil + } + flow := &Flow{ - Type: TypeForm, - ID: genFlowID(), - StepID: StepInit, - Schema: []FlowSchemaItem{ - { - Type: "string", - Name: "username", - Required: true, - }, - { - Type: "string", - Name: "password", - Required: true, - }, - }, + Type: TypeForm, + ID: genFlowID(), + StepID: StepInit, + Schema: sch, Handler: r.Handler, Errors: []string{}, request: r, @@ -116,10 +122,10 @@ func (a *Authenticator) NewFlow(r *FlowRequest) *Flow { return flow } -func (f *Flow) progress(c echo.Context) error { +func (f *Flow) progress(a *Authenticator, c echo.Context) error { switch f.StepID { case StepInit: - var rm map[string]interface{} + rm := make(map[string]interface{}) err := c.Bind(&rm) if err != nil { @@ -133,7 +139,20 @@ func (f *Flow) progress(c echo.Context) error { } } } - // TODO: lookup creds, pass, redirect to url + err = a.Check(f, rm) + switch err { + case nil: + return c.String(http.StatusOK, "login success!") + case ErrInvalidHandler: + return c.String(http.StatusNotFound, err.Error()) + case ErrInvalidAuth: + fallthrough + default: + f.Errors = map[string]interface{}{ + "base": "invalid_auth", + } + return c.JSON(http.StatusOK, f) + } default: return c.String(http.StatusBadRequest, "unknown flow step") } @@ -156,7 +175,7 @@ func (a *Authenticator) LoginFlowDeleteHandler(c echo.Context) error { func (a *Authenticator) LoginFlowHandler(c echo.Context) error { if c.Request().Method == http.MethodPost && strings.HasPrefix(c.Request().Header.Get(echo.HeaderContentType), "text/plain") { // hack around the content-type, Context.JSON refuses to work otherwise - c.Request().Header.Set(echo.HeaderContentType, "application/json") + c.Request().Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8) } flowID := c.Param("flow_id") @@ -171,6 +190,10 @@ func (a *Authenticator) LoginFlowHandler(c echo.Context) error { resp := a.NewFlow(&flowReq) + if resp == nil { + return c.String(http.StatusBadRequest, "no such handler") + } + return c.JSON(http.StatusOK, resp) default: flow := a.Flows.Get(FlowID(flowID)) @@ -178,6 +201,6 @@ func (a *Authenticator) LoginFlowHandler(c echo.Context) error { return c.String(http.StatusNotFound, "no such flow") } - return flow.progress(c) + return flow.progress(a, c) } } diff --git a/pkg/auth/provider.go b/pkg/auth/provider.go new file mode 100644 index 0000000..a1f4fe5 --- /dev/null +++ b/pkg/auth/provider.go @@ -0,0 +1,107 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + + "golang.org/x/crypto/bcrypt" + + "dynatron.me/x/blasphem/pkg/storage" +) + +const ( + HAProviderKey = "auth_provider.homeassistant" +) + +type User struct { + Password string `json:"password"` + Username string `json:"username"` +} +type HomeAssistantProvider struct { + AuthProviderBase `json:"-"` + Users []User `json:"users"` + + salt string `json:"-"` +} + +func NewHAProvider(s storage.Store) (*HomeAssistantProvider, error) { + hap := &HomeAssistantProvider{ + AuthProviderBase: AuthProviderBase{ + Name: "Home Assistant Local", + Type: HomeAssistant, + }, + } + + err := s.Get(HAProviderKey, hap) + if err != nil { + return hap, err + } + + return hap, nil +} + +func genSalt() string { + b := make([]byte, 32) + rand.Read(b) + + return hex.EncodeToString(b) +} + +func (hap *HomeAssistantProvider) hashPass(p string) ([]byte, error) { + return bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost) +} + +func (hap *HomeAssistantProvider) ValidateCreds(rm map[string]interface{}) bool { + usernameE, hasU := rm["username"] + passwordE, hasP := rm["password"] + username, unStr := usernameE.(string) + password, paStr := passwordE.(string) + if !hasU || !hasP || !unStr || !paStr || username == "" || password == "" { + return false + } + + var found *User + + const dummyHash = "$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO" + + for _, v := range hap.Users { // iterate all to thwart timing attacks + if v.Username == username { + found = &v + } + } + + if found == nil { // one more compare to thwart timing attacks + bcrypt.CompareHashAndPassword([]byte("foo"), []byte(dummyHash)) + return false + } + + var hash []byte + hash, err := base64.StdEncoding.DecodeString(found.Password) + if err != nil { + // XXX: probably log this + return false + } + + err = bcrypt.CompareHashAndPassword(hash, []byte(password)) + if err == nil { + return true + } + + return false +} + +func (hap *HomeAssistantProvider) FlowSchema() []FlowSchemaItem { + return []FlowSchemaItem{ + { + Type: "string", + Name: "username", + Required: true, + }, + { + Type: "string", + Name: "password", + Required: true, + }, + } +} diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 2af8776..df50afd 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -1,12 +1,74 @@ package bus -import () +import ( + "sync" +) -type Bus struct { -} +type ( + Event struct { + EvType string + Data interface{} + } + + listeners []chan<- Event + + Bus struct { + sync.RWMutex + subs map[string]listeners + } +) func New() *Bus { - bus := &Bus{} + bus := &Bus{ + subs: make(map[string]listeners), + } return bus + +} + +func (b *Bus) Sub(topic string, ch chan<- Event) { + b.Lock() + defer b.Unlock() + + if prev, ok := b.subs[topic]; ok { + b.subs[topic] = append(prev, ch) + } else { + b.subs[topic] = append(listeners{}, ch) + } +} + +func (b *Bus) Unsub(topic string, ch chan<- Event) { + b.Lock() + defer b.Unlock() + + for i, v := range b.subs[topic] { + if v == ch { + // we don't care about order, replace and reslice + b.subs[topic][i] = b.subs[topic][len(b.subs[topic])-1] + b.subs[topic] = b.subs[topic][:len(b.subs[topic])-1] + } + } +} + +func (b *Bus) Pub(topic string, data interface{}) { + b.RLock() + defer b.RUnlock() + + tc, ok := b.subs[topic] + if !ok { + return + } + + for _, ch := range tc { + ch <- Event{EvType: topic, Data: data} + } +} + +func (b *Bus) Shutdown() { + for _, v := range b.subs { + for _, c := range v { + close(c) + } + } } diff --git a/pkg/config/read.go b/pkg/config/read.go index f094ceb..aad1408 100644 --- a/pkg/config/read.go +++ b/pkg/config/read.go @@ -11,6 +11,7 @@ import ( ) func ReadConfig() (*Config, error) { + // TODO: make this use an arg or an env, and unify with pkg/storage/'s home determination cfg := &Config{} home, err := os.UserHomeDir() diff --git a/pkg/server/server.go b/pkg/server/server.go index aa5d478..c2b26cb 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -1,18 +1,25 @@ package server import ( + "context" "io/fs" - "log" + "net/http" + "os" + "os/signal" "sync" + "time" "github.com/labstack/echo/v4" + "dynatron.me/x/blasphem/internal/common" "dynatron.me/x/blasphem/pkg/auth" "dynatron.me/x/blasphem/pkg/blas" "dynatron.me/x/blasphem/pkg/config" "dynatron.me/x/blasphem/pkg/frontend" ) +const LogHeader = `${time_rfc3339} ${level} ${prefix} ${short_file}:${line}` + type Server struct { *blas.Blas *echo.Echo @@ -42,28 +49,54 @@ func New(cfg *config.Config) (s *Server, err error) { Blas: b, Echo: echo.New(), } - s.InitAuth() + err = s.InitAuth(b.Store) + if err != nil { + return s, err + } s.Echo.Debug = true s.Echo.HideBanner = true + s.Echo.Logger.SetPrefix(common.AppName) + s.Echo.Logger.SetHeader(LogHeader) s.installRoutes() return s, nil } +func (s *Server) Shutdown(ctx context.Context) error { + err := s.Blas.Shutdown(ctx) + if err != nil { + return err + } + + return s.Echo.Shutdown(ctx) +} + func (s *Server) Go() error { s.wg.Add(1) go func() { err := s.Start(s.Config.Server.Bind) - if err != nil { - log.Fatal(err) + if err != nil && err != http.ErrServerClosed { + s.Logger.Fatal(err) } s.wg.Done() }() + // Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds. + // Use a buffered channel to avoid missing signals as recommended for signal.Notify + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt) + <-quit + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := s.Shutdown(ctx); err != nil { + s.Logger.Fatal(err) + } + s.wg.Wait() + s.Logger.Info("shutdown complete") return nil } diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 7e2cd13..80e3231 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -1,6 +1,8 @@ package storage import ( + "encoding/json" + "fmt" "io/fs" ) @@ -14,15 +16,43 @@ type Item struct { Data Data `json:"data"` } -type Store struct { +type store struct { fs.FS } -func Open(dir fs.FS) (*Store, error) { +type Store interface { + Get(key string, data interface{}) error +} + +func (s *store) Get(key string, data interface{}) error { + f, err := s.Open(key) + if err != nil { + return err + } + + defer f.Close() + + item := Item{ + Data: data, + } + d := json.NewDecoder(f) + err = d.Decode(&item) + if err != nil { + return err + } + + if item.Key != key { + return fmt.Errorf("key mismatch '%s' != '%s'", item.Key, key) + } + + return nil +} + +func Open(dir fs.FS) (*store, error) { stor, err := fs.Sub(dir, ".storage") if err != nil { return nil, err } - return &Store{stor}, nil + return &store{stor}, nil }