diff --git a/pkg/auth/authenticator.go b/pkg/auth/authenticator.go new file mode 100644 index 0000000..46edf2e --- /dev/null +++ b/pkg/auth/authenticator.go @@ -0,0 +1,73 @@ +package auth + +import ( + "io" + "net/http" + + "github.com/labstack/echo/v4" + + "dynatron.me/x/blasphem/pkg/frontend" +) + +type Authenticator struct { + Flows FlowStore +} + +func (a *Authenticator) InitAuth() { + a.Flows = make(FlowStore) +} + +type AuthProvider interface { + ProviderName() string + ProviderID() *string + ProviderType() string +} + +type AuthProviderBase struct { + Name string `json:"name"` + ID *string `json:"id"` + 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 +} + +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(), + } + + return c.JSON(http.StatusOK, providers) +} + +func (s *Authenticator) AuthorizeHandler(c echo.Context) error { + authContents, err := frontend.RootFS.Open("authorize.html") + if err != nil { + return err + } + defer authContents.Close() + + b, err := io.ReadAll(authContents) + if err != nil { + return err + } + + return c.HTML(http.StatusOK, string(b)) +} diff --git a/pkg/auth/flow.go b/pkg/auth/flow.go new file mode 100644 index 0000000..7502a72 --- /dev/null +++ b/pkg/auth/flow.go @@ -0,0 +1,171 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v4" +) + +type FlowStore map[FlowID]*Flow + +type FlowRequest struct { + ClientID string `json:"client_id"` + Handler []*string `json:"handler"` + RedirectURI string `json:"redirect_uri"` +} + +type FlowSchemaItem struct { + Type string `json:"type"` + Name string `json:"name"` + Required bool `json:"required"` +} + +type FlowType string + +const ( + TypeForm FlowType = "form" +) + +type FlowID string +type Step string + +const ( + StepInit Step = "init" +) + +type Flow struct { + Type FlowType `json:"type"` + ID FlowID `json:"flow_id"` + Handler []*string `json:"handler"` + StepID Step `json:"step_id"` + Schema []FlowSchemaItem `json:"data_schema"` + Errors []string `json:"errors"` + DescPlace *string `json:"description_placeholders"` + LastStep *string `json:"last_step"` + + request *FlowRequest + age time.Time +} + +func (f *Flow) touch() { + f.age = time.Now() +} + +func genFlowID() FlowID { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + + return FlowID(hex.EncodeToString(b)) +} + +func (fs FlowStore) register(f *Flow) { + fs.cull() + fs[f.ID] = f +} + +const cullAge = time.Minute * 30 + +func (fs FlowStore) cull() { + for k, v := range fs { + if time.Now().Sub(v.age) > cullAge { + delete(fs, k) + } + } +} + +func (fs FlowStore) Get(id FlowID) *Flow { + f, ok := fs[id] + if ok { + return f + } + + return nil +} + +func (a *Authenticator) NewFlow(r *FlowRequest) *Flow { + flow := &Flow{ + Type: TypeForm, + ID: genFlowID(), + StepID: StepInit, + Schema: []FlowSchemaItem{ + { + Type: "string", + Name: "username", + Required: true, + }, + { + Type: "string", + Name: "password", + Required: true, + }, + }, + Handler: r.Handler, + Errors: []string{}, + request: r, + } + flow.touch() + + a.Flows.register(flow) + + return flow +} + +func (f *Flow) progress(c echo.Context) error { + switch f.StepID { + case StepInit: + var rm map[string]interface{} + + err := c.Bind(&rm) + if err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + + for _, si := range f.Schema { + if si.Required { + if _, ok := rm[si.Name]; !ok { + return c.String(http.StatusBadRequest, "missing required param "+si.Name) + } + } + } + // TODO: lookup creds, pass, redirect to url + default: + return c.String(http.StatusBadRequest, "unknown flow step") + } + + return nil +} + +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") + } + + flowID := c.Param("flow_id") + + switch flowID { + case "": // new + var flowReq FlowRequest + err := c.Bind(&flowReq) + if err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + + resp := a.NewFlow(&flowReq) + + return c.JSON(http.StatusOK, resp) + default: + flow := a.Flows.Get(FlowID(flowID)) + if flow == nil { + return c.String(http.StatusNotFound, "no such flow") + } + + return flow.progress(c) + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 75d6bfa..e79f083 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -50,6 +50,8 @@ func New(cfg *config.Config) (s *Server, err error) { Echo: echo.New(), cfg: cfg, } + s.InitAuth() + s.Echo.Debug = true s.Echo.HideBanner = true @@ -58,6 +60,7 @@ func New(cfg *config.Config) (s *Server, err error) { s.GET("/auth/authorize", s.AuthorizeHandler) s.GET("/auth/providers", s.ProvidersHandler) s.POST("/auth/login_flow", s.LoginFlowHandler) + s.POST("/auth/login_flow/:flow_id", s.LoginFlowHandler) return s, nil }