WIP: Login "works"

This commit is contained in:
Daniel 2022-10-25 00:16:29 -04:00
parent 17149f2c58
commit cb4ccc37b2
10 changed files with 367 additions and 58 deletions

View file

@ -25,6 +25,6 @@ func main() {
err = rootCmd.Execute() err = rootCmd.Execute()
if err != nil { if err != nil {
panic(err) log.Fatal(err)
} }
} }

8
go.mod
View file

@ -17,8 +17,8 @@ require (
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect github.com/valyala/fasttemplate v1.2.1 // indirect
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect golang.org/x/crypto v0.1.0 // indirect
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect golang.org/x/net v0.1.0 // indirect
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect golang.org/x/sys v0.1.0 // indirect
golang.org/x/text v0.3.7 // indirect golang.org/x/text v0.4.0 // indirect
) )

8
go.sum
View file

@ -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= 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 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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 h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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-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-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 h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

View file

@ -1,26 +1,57 @@
package auth package auth
import ( import (
"errors"
"io" "io"
"net/http" "net/http"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"dynatron.me/x/blasphem/pkg/frontend" "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 { 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) 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 { type AuthProvider interface {
ProviderName() string ProviderName() string
ProviderID() *string ProviderID() *string
ProviderType() string ProviderType() string
ProviderBase() AuthProviderBase
FlowSchema() []FlowSchemaItem
ValidateCreds(reqMap map[string]interface{}) bool
} }
type AuthProviderBase struct { type AuthProviderBase struct {
@ -29,29 +60,17 @@ type AuthProviderBase struct {
Type string `json:"type"` Type string `json:"type"`
} }
func (bp *AuthProviderBase) ProviderName() string { return bp.Name } func (bp *AuthProviderBase) ProviderName() string { return bp.Name }
func (bp *AuthProviderBase) ProviderID() *string { return bp.ID } func (bp *AuthProviderBase) ProviderID() *string { return bp.ID }
func (bp *AuthProviderBase) ProviderType() string { return bp.Type } func (bp *AuthProviderBase) ProviderType() string { return bp.Type }
func (bp *AuthProviderBase) ProviderBase() AuthProviderBase { return *bp }
type LocalProvider struct {
AuthProviderBase
}
var HomeAssistant = "homeassistant" var HomeAssistant = "homeassistant"
func hassProvider() *LocalProvider {
return &LocalProvider{
AuthProviderBase: AuthProviderBase{
Name: "Home Assistant Local",
Type: HomeAssistant,
},
}
}
// TODO: make this configurable // TODO: make this configurable
func (s *Authenticator) ProvidersHandler(c echo.Context) error { func (a *Authenticator) ProvidersHandler(c echo.Context) error {
providers := []AuthProvider{ providers := []AuthProviderBase{
hassProvider(), a.Provider(HomeAssistant).ProviderBase(),
} }
return c.JSON(http.StatusOK, providers) 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)) 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
}

View file

@ -43,7 +43,7 @@ type Flow struct {
Handler []*string `json:"handler"` Handler []*string `json:"handler"`
StepID Step `json:"step_id"` StepID Step `json:"step_id"`
Schema []FlowSchemaItem `json:"data_schema"` Schema []FlowSchemaItem `json:"data_schema"`
Errors []string `json:"errors"` Errors interface{} `json:"errors"`
DescPlace *string `json:"description_placeholders"` DescPlace *string `json:"description_placeholders"`
LastStep *string `json:"last_step"` LastStep *string `json:"last_step"`
@ -89,22 +89,28 @@ func (fs FlowStore) Get(id FlowID) *Flow {
} }
func (a *Authenticator) NewFlow(r *FlowRequest) *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{ flow := &Flow{
Type: TypeForm, Type: TypeForm,
ID: genFlowID(), ID: genFlowID(),
StepID: StepInit, StepID: StepInit,
Schema: []FlowSchemaItem{ Schema: sch,
{
Type: "string",
Name: "username",
Required: true,
},
{
Type: "string",
Name: "password",
Required: true,
},
},
Handler: r.Handler, Handler: r.Handler,
Errors: []string{}, Errors: []string{},
request: r, request: r,
@ -116,10 +122,10 @@ func (a *Authenticator) NewFlow(r *FlowRequest) *Flow {
return flow return flow
} }
func (f *Flow) progress(c echo.Context) error { func (f *Flow) progress(a *Authenticator, c echo.Context) error {
switch f.StepID { switch f.StepID {
case StepInit: case StepInit:
var rm map[string]interface{} rm := make(map[string]interface{})
err := c.Bind(&rm) err := c.Bind(&rm)
if err != nil { 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: default:
return c.String(http.StatusBadRequest, "unknown flow step") 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 { func (a *Authenticator) LoginFlowHandler(c echo.Context) error {
if c.Request().Method == http.MethodPost && strings.HasPrefix(c.Request().Header.Get(echo.HeaderContentType), "text/plain") { 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 // 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") flowID := c.Param("flow_id")
@ -171,6 +190,10 @@ func (a *Authenticator) LoginFlowHandler(c echo.Context) error {
resp := a.NewFlow(&flowReq) resp := a.NewFlow(&flowReq)
if resp == nil {
return c.String(http.StatusBadRequest, "no such handler")
}
return c.JSON(http.StatusOK, resp) return c.JSON(http.StatusOK, resp)
default: default:
flow := a.Flows.Get(FlowID(flowID)) 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 c.String(http.StatusNotFound, "no such flow")
} }
return flow.progress(c) return flow.progress(a, c)
} }
} }

107
pkg/auth/provider.go Normal file
View file

@ -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,
},
}
}

View file

@ -1,12 +1,74 @@
package bus 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 { func New() *Bus {
bus := &Bus{} bus := &Bus{
subs: make(map[string]listeners),
}
return bus 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)
}
}
} }

View file

@ -11,6 +11,7 @@ import (
) )
func ReadConfig() (*Config, error) { func ReadConfig() (*Config, error) {
// TODO: make this use an arg or an env, and unify with pkg/storage/'s home determination
cfg := &Config{} cfg := &Config{}
home, err := os.UserHomeDir() home, err := os.UserHomeDir()

View file

@ -1,18 +1,25 @@
package server package server
import ( import (
"context"
"io/fs" "io/fs"
"log" "net/http"
"os"
"os/signal"
"sync" "sync"
"time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"dynatron.me/x/blasphem/internal/common"
"dynatron.me/x/blasphem/pkg/auth" "dynatron.me/x/blasphem/pkg/auth"
"dynatron.me/x/blasphem/pkg/blas" "dynatron.me/x/blasphem/pkg/blas"
"dynatron.me/x/blasphem/pkg/config" "dynatron.me/x/blasphem/pkg/config"
"dynatron.me/x/blasphem/pkg/frontend" "dynatron.me/x/blasphem/pkg/frontend"
) )
const LogHeader = `${time_rfc3339} ${level} ${prefix} ${short_file}:${line}`
type Server struct { type Server struct {
*blas.Blas *blas.Blas
*echo.Echo *echo.Echo
@ -42,28 +49,54 @@ func New(cfg *config.Config) (s *Server, err error) {
Blas: b, Blas: b,
Echo: echo.New(), Echo: echo.New(),
} }
s.InitAuth() err = s.InitAuth(b.Store)
if err != nil {
return s, err
}
s.Echo.Debug = true s.Echo.Debug = true
s.Echo.HideBanner = true s.Echo.HideBanner = true
s.Echo.Logger.SetPrefix(common.AppName)
s.Echo.Logger.SetHeader(LogHeader)
s.installRoutes() s.installRoutes()
return s, nil 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 { func (s *Server) Go() error {
s.wg.Add(1) s.wg.Add(1)
go func() { go func() {
err := s.Start(s.Config.Server.Bind) err := s.Start(s.Config.Server.Bind)
if err != nil { if err != nil && err != http.ErrServerClosed {
log.Fatal(err) s.Logger.Fatal(err)
} }
s.wg.Done() 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.wg.Wait()
s.Logger.Info("shutdown complete")
return nil return nil
} }

View file

@ -1,6 +1,8 @@
package storage package storage
import ( import (
"encoding/json"
"fmt"
"io/fs" "io/fs"
) )
@ -14,15 +16,43 @@ type Item struct {
Data Data `json:"data"` Data Data `json:"data"`
} }
type Store struct { type store struct {
fs.FS 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") stor, err := fs.Sub(dir, ".storage")
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Store{stor}, nil return &store{stor}, nil
} }