WIP: Login "works"

This commit is contained in:
Daniel Ponte 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()
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/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
)

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=
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=

View file

@ -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
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 {
@ -32,26 +63,14 @@ type AuthProviderBase struct {
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) 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
}

View file

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

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
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)
}
}
}

View file

@ -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()

View file

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

View file

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