Compare commits

..

No commits in common. "initwip" and "sessions" have entirely different histories.

30 changed files with 380 additions and 1572 deletions

View file

@ -1,14 +1,11 @@
FE=pkg/frontend/frontend FE=pkg/frontend/frontend
VER=$(shell git describe --always --tags)
LDFLAGS=-ldflags='-X dynatron.me/x/blasphem/internal/common.Version=${VER}'
all: build all: build
build: build:
go build ${LDFLAGS} -o blas ./cmd/blas/ go build -o blas ./cmd/blas/
serve: serve:
go run ${LDFLAGS} ./cmd/blas/ serve ${BLAS_ARGS} go run ./cmd/blas/ serve ${BLAS_ARGS}
# pkg/frontend/frontend/hass_frontend: # pkg/frontend/frontend/hass_frontend:
frontend: frontend:

View file

@ -7,7 +7,6 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"dynatron.me/x/blasphem/internal/common" "dynatron.me/x/blasphem/internal/common"
"dynatron.me/x/blasphem/pkg/blas"
"dynatron.me/x/blasphem/pkg/cmd/serve" "dynatron.me/x/blasphem/pkg/cmd/serve"
"dynatron.me/x/blasphem/pkg/config" "dynatron.me/x/blasphem/pkg/config"
@ -26,12 +25,7 @@ func main() {
log.Fatal().Err(err).Msg("Config read failed") log.Fatal().Err(err).Msg("Config read failed")
} }
bl, err := blas.New(config) rootCmd.AddCommand(serve.Command(config))
if err != nil {
log.Fatal().Err(err).Msg("Core create failed")
}
rootCmd.AddCommand(serve.Command(bl))
err = rootCmd.Execute() err = rootCmd.Execute()
if err != nil { if err != nil {

1
go.mod
View file

@ -21,7 +21,6 @@ require (
github.com/labstack/gommon v0.3.1 // indirect github.com/labstack/gommon v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
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

2
go.sum
View file

@ -36,8 +36,6 @@ github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2y
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

View file

@ -2,13 +2,9 @@
package common package common
import ( import (
"github.com/labstack/echo/v4"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// this symbol overriden by linker args
var Version = "undefined"
const ( const (
// AppName is the name of the application. // AppName is the name of the application.
AppName = "blasphem" AppName = "blasphem"
@ -19,13 +15,6 @@ type cmdOptions interface {
Execute() error Execute() error
} }
func AppNamePtr() *string {
s := AppName
return &s
}
func IntPtr(i int) *int { return &i }
// RunE is a convenience function for use with cobra. // RunE is a convenience function for use with cobra.
func RunE(c cmdOptions) func(cmd *cobra.Command, args []string) error { func RunE(c cmdOptions) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error {
@ -43,9 +32,3 @@ func RunE(c cmdOptions) func(cmd *cobra.Command, args []string) error {
return err return err
} }
} }
func NoCache(c echo.Context) echo.Context {
c.Response().Header().Set("Cache-Control", "no-store")
c.Response().Header().Set("Pragma", "no-cache")
return c
}

View file

@ -11,7 +11,6 @@ import (
type ( type (
// PyTimeStamp is a timestamp that marshals to python-style timestamp strings (long nano). // PyTimeStamp is a timestamp that marshals to python-style timestamp strings (long nano).
PyTimestamp time.Time PyTimestamp time.Time
ClientID string
) )
const PytTimeFormat = "2006-01-02T15:04:05.999999-07:00" const PytTimeFormat = "2006-01-02T15:04:05.999999-07:00"

View file

@ -3,12 +3,12 @@ package auth
import ( import (
"errors" "errors"
"net/http" "net/http"
"sync"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"dynatron.me/x/blasphem/pkg/auth/provider" "dynatron.me/x/blasphem/pkg/auth/provider"
"dynatron.me/x/blasphem/pkg/frontend"
"dynatron.me/x/blasphem/pkg/storage" "dynatron.me/x/blasphem/pkg/storage"
// providers // providers
@ -20,30 +20,23 @@ var (
ErrDisabled = errors.New("user disabled") ErrDisabled = errors.New("user disabled")
ErrInvalidAuth = errors.New("invalid auth") ErrInvalidAuth = errors.New("invalid auth")
ErrInvalidHandler = errors.New("no such handler") ErrInvalidHandler = errors.New("no such handler")
ErrInvalidIP = errors.New("invalid IP")
ErrUserAuthRemote = errors.New("user cannot authenticate remotely")
) )
type authenticator struct { type Authenticator struct {
sync.Mutex
store AuthStore store AuthStore
flows *AuthFlowManager flows FlowStore
authCodes authCodeStore sessions AccessSessionStore
providers map[string]provider.AuthProvider providers map[string]provider.AuthProvider
} }
type Authenticator interface {
ValidateAccessToken(token AccessToken) *RefreshToken
InstallRoutes(e *echo.Echo)
}
type AuthError struct { type AuthError struct {
Error string `json:"error"` Error string `json:"error"`
Description string `json:"error_description"` Description string `json:"error_description"`
} }
func (a *authenticator) InstallRoutes(e *echo.Echo) { func (a *Authenticator) InstallRoutes(e *echo.Echo) {
authG := e.Group("/auth") authG := e.Group("/auth")
authG.GET("/authorize", frontend.AliasHandler("authorize.html"))
authG.GET("/providers", a.ProvidersHandler) authG.GET("/providers", a.ProvidersHandler)
authG.POST("/token", a.TokenHandler) authG.POST("/token", a.TokenHandler)
@ -54,34 +47,31 @@ func (a *authenticator) InstallRoutes(e *echo.Echo) {
loginFlow.DELETE("/:flow_id", a.LoginFlowDeleteHandler) loginFlow.DELETE("/:flow_id", a.LoginFlowDeleteHandler)
} }
func New(s storage.Store) (Authenticator, error) { func (a *Authenticator) InitAuth(s storage.Store) error {
a := &authenticator{ a.providers = make(map[string]provider.AuthProvider)
providers: make(map[string]provider.AuthProvider),
}
for _, pI := range provider.Providers { for _, pI := range provider.Providers {
nProv, err := pI(s) nProv, err := pI(s)
if err != nil { if err != nil {
return nil, err return err
} }
a.providers[nProv.ProviderType()] = nProv a.providers[nProv.ProviderType()] = nProv
} }
a.flows = NewAuthFlowManager() a.flows = make(FlowStore)
a.authCodes.init() a.sessions.init()
var err error var err error
a.store, err = a.newAuthStore(s) a.store, err = a.newAuthStore(s)
if err != nil { if err != nil {
return nil, err return err
} }
return a, nil return nil
} }
func (a *authenticator) Provider(name string) provider.AuthProvider { func (a *Authenticator) Provider(name string) provider.AuthProvider {
p, ok := a.providers[name] p, ok := a.providers[name]
if !ok { if !ok {
return nil return nil
@ -93,7 +83,7 @@ func (a *authenticator) Provider(name string) provider.AuthProvider {
var HomeAssistant = "homeassistant" var HomeAssistant = "homeassistant"
// TODO: make this configurable // TODO: make this configurable
func (a *authenticator) ProvidersHandler(c echo.Context) error { func (a *Authenticator) ProvidersHandler(c echo.Context) error {
providers := []provider.AuthProviderBase{ providers := []provider.AuthProviderBase{
a.Provider(HomeAssistant).ProviderBase(), a.Provider(HomeAssistant).ProviderBase(),
} }
@ -101,24 +91,30 @@ func (a *authenticator) ProvidersHandler(c echo.Context) error {
return c.JSON(http.StatusOK, providers) return c.JSON(http.StatusOK, providers)
} }
func (a *authenticator) Check(f *LoginFlow, req *http.Request, rm map[string]interface{}) (user provider.ProviderUser, clientID string, err error) { func (a *Authenticator) Check(f *Flow, req *http.Request, rm map[string]interface{}) (provider.ProviderUser, error) {
cID, hasCID := rm["client_id"] cID, hasCID := rm["client_id"]
clientID, cidIsStr := cID.(string) cIDStr, cidIsStr := cID.(string)
if !hasCID || !cidIsStr || clientID == "" || clientID != string(f.ClientID) { if !hasCID || !cidIsStr || cIDStr == "" || cIDStr != string(f.request.ClientID) {
return nil, clientID, ErrInvalidAuth return nil, ErrInvalidAuth
} }
p := a.Provider(f.Handler.String()) for _, h := range f.Handler {
if p == nil { if h == nil {
return nil, clientID, ErrInvalidAuth return nil, ErrInvalidHandler
}
p := a.Provider(*h)
if p == nil {
return nil, ErrInvalidAuth
}
user, success := p.ValidateCreds(req, rm)
if success {
log.Info().Interface("user", user.UserData()).Msg("Login success")
return user, nil
}
} }
user, success := p.ValidateCreds(req, rm) return nil, ErrInvalidAuth
if success {
log.Info().Interface("user", user.UserData()).Msg("Login success")
return user, clientID, nil
}
return nil, clientID, ErrInvalidAuth
} }

View file

@ -1,100 +1,134 @@
package auth package auth
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/jinzhu/copier" "github.com/jinzhu/copier"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"dynatron.me/x/blasphem/internal/common" "dynatron.me/x/blasphem/internal/common"
"dynatron.me/x/blasphem/internal/generate"
"dynatron.me/x/blasphem/pkg/auth/provider" "dynatron.me/x/blasphem/pkg/auth/provider"
"dynatron.me/x/blasphem/pkg/flow"
) )
type AuthFlowManager struct { type FlowStore map[FlowID]*Flow
*flow.FlowManager
type FlowRequest struct {
ClientID ClientID `json:"client_id"`
Handler []*string `json:"handler"`
RedirectURI string `json:"redirect_uri"`
} }
type LoginFlow struct { type FlowType string
flow.FlowHandler
prov provider.AuthProvider const (
ClientID common.ClientID TypeForm FlowType = "form"
FlowContext TypeCreateEntry FlowType = "create_entry"
)
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,omitempty"`
Schema []provider.FlowSchemaItem `json:"data_schema"`
Errors interface{} `json:"errors"`
DescPlace *string `json:"description_placeholders"`
LastStep *string `json:"last_step"`
request *FlowRequest
ctime time.Time
} }
type FlowContext struct { func (f *Flow) touch() {
IPAddr string f.ctime = time.Now()
CredentialOnly bool
RedirectURI string
} }
type LoginFlowRequest struct { func (fs FlowStore) register(f *Flow) {
ClientID common.ClientID `json:"client_id"` fs.cull()
Handler []*string `json:"handler"` fs[f.ID] = f
RedirectURI string `json:"redirect_uri"`
Type *string `json:"type"`
ip string `json:"-"`
} }
func (r *LoginFlowRequest) FlowContext() FlowContext { func (fs FlowStore) Remove(f *Flow) {
return FlowContext{ delete(fs, f.ID)
IPAddr: r.ip, }
RedirectURI: r.RedirectURI,
CredentialOnly: r.Type != nil && *r.Type == "link_user", const cullAge = time.Minute * 30
func (fs FlowStore) cull() {
for k, v := range fs {
if time.Now().Sub(v.ctime) > cullAge {
delete(fs, k)
}
} }
} }
func NewAuthFlowManager() *AuthFlowManager { func (fs FlowStore) Get(id FlowID) *Flow {
return &AuthFlowManager{FlowManager: flow.NewFlowManager()} f, ok := fs[id]
} if ok {
return f
func (afm *AuthFlowManager) NewLoginFlow(req *LoginFlowRequest, prov provider.AuthProvider) *LoginFlow {
lf := &LoginFlow{
FlowHandler: flow.NewFlowHandlerBase(prov.ProviderType()),
ClientID: req.ClientID,
FlowContext: req.FlowContext(),
prov: prov,
} }
afm.Register(lf) return nil
return lf
} }
func (a *authenticator) NewFlow(r *LoginFlowRequest) *flow.Result { func (a *Authenticator) NewFlow(r *FlowRequest) *Flow {
var prov provider.AuthProvider var sch []provider.FlowSchemaItem
for _, h := range r.Handler { for _, h := range r.Handler {
if h == nil { if h == nil {
break break
} }
prov = a.Provider(*h) if hand := a.Provider(*h); hand != nil {
if prov != nil { sch = hand.FlowSchema()
break break
} }
} }
if prov == nil { if sch == nil {
return nil return nil
} }
lf := a.flows.NewLoginFlow(r, prov) flow := &Flow{
Type: TypeForm,
ID: FlowID(generate.UUID()),
StepID: stepPtr(StepInit),
Schema: sch,
Handler: r.Handler,
Errors: []string{},
request: r,
}
flow.touch()
return lf.ShowForm(lf.WithSchema(prov), lf.WithStep(flow.StepInit)) a.flows.register(flow)
return flow
} }
func (f *LoginFlow) redirect(c echo.Context) { func stepPtr(s Step) *Step { return &s }
c.Request().Header.Set("Location", f.RedirectURI)
func (f *Flow) redirect(c echo.Context) {
c.Request().Header.Set("Location", f.request.RedirectURI)
} }
func (f *LoginFlow) progress(a *authenticator, c echo.Context) error { func (f *Flow) progress(a *Authenticator, c echo.Context) error {
switch f.Step() { if f.StepID == nil {
case flow.StepInit: c.Logger().Error("stepID is nil")
return c.String(http.StatusInternalServerError, "No Step ID")
}
switch *f.StepID {
case StepInit:
rm := make(map[string]interface{}) rm := make(map[string]interface{})
err := c.Bind(&rm) err := c.Bind(&rm)
@ -102,26 +136,31 @@ func (f *LoginFlow) progress(a *authenticator, c echo.Context) error {
return c.String(http.StatusBadRequest, err.Error()) return c.String(http.StatusBadRequest, err.Error())
} }
err = f.prov.FlowSchema().CheckRequired(rm) for _, si := range f.Schema {
if err != nil { if si.Required {
return c.JSON(http.StatusBadRequest, f.ShowForm(f.WithErrors([]string{err.Error()}))) if _, ok := rm[si.Name]; !ok {
return c.String(http.StatusBadRequest, "missing required param "+si.Name)
}
}
} }
user, err := a.Check(f, c.Request(), rm)
user, clientID, err := a.Check(f, c.Request(), rm)
switch err { switch err {
case nil: case nil:
creds := a.store.GetCredential(user) var finishedFlow struct {
if creds == nil { ID FlowID `json:"flow_id"`
return fmt.Errorf("flow progress: no such credential for %v", user.UserData()) Handler []*string `json:"handler"`
Result AccessTokenID `json:"result"`
Title string `json:"title"`
Type FlowType `json:"type"`
Version int `json:"version"`
} }
finishedFlow := flow.Result{}
a.flows.Remove(f) a.flows.Remove(f)
copier.Copy(&finishedFlow, f) copier.Copy(&finishedFlow, f)
finishedFlow.Type = flow.TypeCreateEntry finishedFlow.Type = TypeCreateEntry
finishedFlow.Title = common.AppNamePtr() finishedFlow.Title = common.AppName
finishedFlow.Version = common.IntPtr(1) finishedFlow.Version = 1
finishedFlow.Result = a.NewAuthCode(ClientID(clientID), creds) finishedFlow.Result = a.NewAccessToken(c.Request(), user, f)
f.redirect(c) f.redirect(c)
@ -131,29 +170,24 @@ func (f *LoginFlow) progress(a *authenticator, c echo.Context) error {
case ErrInvalidAuth: case ErrInvalidAuth:
fallthrough fallthrough
default: default:
return c.JSON(http.StatusOK, f.ShowForm(f.WithErrors(map[string]interface{}{ f.Errors = map[string]interface{}{
"base": "invalid_auth", "base": "invalid_auth",
}))) }
return c.JSON(http.StatusOK, f)
} }
default: default:
return c.JSON(http.StatusOK, f.ShowForm(f.WithErrors(map[string]interface{}{ return c.String(http.StatusBadRequest, "unknown flow step")
"base": "unknown_flow_step",
})))
} }
} }
func (a *authenticator) LoginFlowDeleteHandler(c echo.Context) error { func (a *Authenticator) LoginFlowDeleteHandler(c echo.Context) error {
a.Lock() flowID := c.Param("flow_id")
defer a.Unlock()
flowID := flow.FlowID(c.Param("flow_id"))
if flowID == "" { if flowID == "" {
return c.String(http.StatusBadRequest, "empty flow ID") return c.String(http.StatusBadRequest, "empty flow ID")
} }
a.flows.Delete(flowID) delete(a.flows, FlowID(flowID))
return c.String(http.StatusOK, "deleted") return c.String(http.StatusOK, "deleted")
} }
@ -165,20 +199,15 @@ func setJSON(c echo.Context) {
} }
} }
func (a *authenticator) BeginLoginFlowHandler(c echo.Context) error { func (a *Authenticator) BeginLoginFlowHandler(c echo.Context) error {
a.Lock()
defer a.Unlock()
setJSON(c) setJSON(c)
var flowReq LoginFlowRequest var flowReq FlowRequest
err := c.Bind(&flowReq) err := c.Bind(&flowReq)
if err != nil { if err != nil {
return c.String(http.StatusBadRequest, err.Error()) return c.String(http.StatusBadRequest, err.Error())
} }
flowReq.ip = c.Request().RemoteAddr
resp := a.NewFlow(&flowReq) resp := a.NewFlow(&flowReq)
if resp == nil { if resp == nil {
@ -188,18 +217,21 @@ func (a *authenticator) BeginLoginFlowHandler(c echo.Context) error {
return c.JSON(http.StatusOK, resp) return c.JSON(http.StatusOK, resp)
} }
func (a *authenticator) LoginFlowHandler(c echo.Context) error { func (a *Authenticator) LoginFlowHandler(c echo.Context) error {
a.Lock()
defer a.Unlock()
setJSON(c) setJSON(c)
flowID := c.Param("flow_id") flowID := c.Param("flow_id")
flow := a.flows.Get(flow.FlowID(flowID)) flow := a.flows.Get(FlowID(flowID))
if flow == nil { if flow == nil {
return c.String(http.StatusNotFound, "no such flow") return c.String(http.StatusNotFound, "no such flow")
} }
return flow.(*LoginFlow).progress(a, c) if time.Now().Sub(flow.ctime) > cullAge {
a.flows.Remove(flow)
return c.String(http.StatusGone, "flow timed out")
}
return flow.progress(a, c)
} }

View file

@ -9,7 +9,6 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"dynatron.me/x/blasphem/pkg/auth/provider" "dynatron.me/x/blasphem/pkg/auth/provider"
"dynatron.me/x/blasphem/pkg/flow"
"dynatron.me/x/blasphem/pkg/storage" "dynatron.me/x/blasphem/pkg/storage"
) )
@ -26,23 +25,12 @@ type HAUser struct {
func (hau *HAUser) UserData() provider.ProviderUser { func (hau *HAUser) UserData() provider.ProviderUser {
return &UserData{ // strip secret return &UserData{ // strip secret
Username: hau.Username, Username: hau.Username,
AuthProvider: hau.AuthProvider,
} }
} }
func (hau *HAUser) Provider() provider.AuthProvider {
return hau.AuthProvider
}
func (hau *UserData) Provider() provider.AuthProvider {
return hau.AuthProvider
}
type UserData struct { type UserData struct {
Username string `json:"username"` Username string `json:"username"`
provider.AuthProvider `json:"-"`
} }
func (ud *UserData) UserData() provider.ProviderUser { func (ud *UserData) UserData() provider.ProviderUser {
@ -56,7 +44,7 @@ func (h *HAUser) ProviderUserData() interface{} { return h.UserData() }
type HomeAssistantProvider struct { type HomeAssistantProvider struct {
provider.AuthProviderBase `json:"-"` provider.AuthProviderBase `json:"-"`
Users []HAUser `json:"users"` Users []HAUser `json:"users"`
userMap map[string]*HAUser userMap map[string]*HAUser
} }
func NewHAProvider(s storage.Store) (provider.AuthProvider, error) { func NewHAProvider(s storage.Store) (provider.AuthProvider, error) {
@ -95,28 +83,6 @@ func (hap *HomeAssistantProvider) hashPass(p string) ([]byte, error) {
return bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost) return bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
} }
func (hap *HomeAssistantProvider) EqualCreds(c1, c2 provider.ProviderUser) bool {
switch c1c := c1.(type) {
case *HAUser:
switch c2c := c2.(type) {
case *HAUser:
return c2c.Username == c1c.Username
case *UserData:
return c2c.Username == c1c.Username
}
case *UserData:
switch c2c := c2.(type) {
case *HAUser:
return c2c.Username == c1c.Username
case *UserData:
return c2c.Username == c1c.Username
}
}
return false
}
func (hap *HomeAssistantProvider) ValidateCreds(r *http.Request, rm map[string]interface{}) (provider.ProviderUser, bool) { func (hap *HomeAssistantProvider) ValidateCreds(r *http.Request, rm map[string]interface{}) (provider.ProviderUser, bool) {
usernameE, hasU := rm["username"] usernameE, hasU := rm["username"]
passwordE, hasP := rm["password"] passwordE, hasP := rm["password"]
@ -150,7 +116,6 @@ func (hap *HomeAssistantProvider) ValidateCreds(r *http.Request, rm map[string]i
err = bcrypt.CompareHashAndPassword(hash, []byte(password)) err = bcrypt.CompareHashAndPassword(hash, []byte(password))
if err == nil { if err == nil {
found.AuthProvider = hap
return found, true return found, true
} }
@ -161,8 +126,19 @@ func (hap *HomeAssistantProvider) NewCredData() interface{} {
return &HAUser{} return &HAUser{}
} }
func (hap *HomeAssistantProvider) FlowSchema() flow.Schema { func (hap *HomeAssistantProvider) FlowSchema() []provider.FlowSchemaItem {
return flow.NewSchema(flow.RequiredString("username"), flow.RequiredString("password")) return []provider.FlowSchemaItem{
{
Type: "string",
Name: "username",
Required: true,
},
{
Type: "string",
Name: "password",
Required: true,
},
}
} }
func init() { func init() {

View file

@ -3,7 +3,6 @@ package provider
import ( import (
"net/http" "net/http"
"dynatron.me/x/blasphem/pkg/flow"
"dynatron.me/x/blasphem/pkg/storage" "dynatron.me/x/blasphem/pkg/storage"
) )
@ -14,10 +13,9 @@ var Providers = make(map[string]Constructor)
type AuthProvider interface { // TODO: this should include stepping type AuthProvider interface { // TODO: this should include stepping
AuthProviderMetadata AuthProviderMetadata
ProviderBase() AuthProviderBase ProviderBase() AuthProviderBase
FlowSchema() flow.Schema FlowSchema() []FlowSchemaItem
NewCredData() interface{} NewCredData() interface{}
ValidateCreds(r *http.Request, reqMap map[string]interface{}) (user ProviderUser, success bool) ValidateCreds(r *http.Request, reqMap map[string]interface{}) (user ProviderUser, success bool)
EqualCreds(c1, c2 ProviderUser) bool
Lookup(ProviderUser) ProviderUser Lookup(ProviderUser) ProviderUser
} }
@ -26,9 +24,8 @@ func Register(providerName string, f func(storage.Store) (AuthProvider, error))
} }
type ProviderUser interface { type ProviderUser interface {
// TODO: make sure this is sane with all the ProviderUser and UserData type stuff // TODO: make sure this is sane with all the ProviderUser and UserData type stuf
UserData() ProviderUser UserData() ProviderUser
Provider() AuthProvider
} }
type AuthProviderBase struct { type AuthProviderBase struct {
@ -47,3 +44,9 @@ 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 } func (bp *AuthProviderBase) ProviderBase() AuthProviderBase { return *bp }
type FlowSchemaItem struct {
Type string `json:"type"`
Name string `json:"name"`
Required bool `json:"required"`
}

View file

@ -6,7 +6,6 @@ import (
"net/http" "net/http"
"dynatron.me/x/blasphem/pkg/auth/provider" "dynatron.me/x/blasphem/pkg/auth/provider"
"dynatron.me/x/blasphem/pkg/flow"
"dynatron.me/x/blasphem/pkg/storage" "dynatron.me/x/blasphem/pkg/storage"
) )
@ -18,23 +17,12 @@ type User struct {
func (hau *User) UserData() provider.ProviderUser { func (hau *User) UserData() provider.ProviderUser {
return &UserData{ return &UserData{
UserID: hau.UserID, UserID: hau.UserID,
AuthProvider: hau.AuthProvider,
} }
} }
func (hau *UserData) Provider() provider.AuthProvider {
return hau.AuthProvider
}
func (hau *User) Provider() provider.AuthProvider {
return hau.AuthProvider
}
type UserData struct { type UserData struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
provider.AuthProvider `json:"-"`
} }
func (ud *UserData) UserData() provider.ProviderUser { func (ud *UserData) UserData() provider.ProviderUser {
@ -47,11 +35,6 @@ type TrustedNetworksProvider struct {
provider.AuthProviderBase `json:"-"` provider.AuthProviderBase `json:"-"`
} }
func (hap *TrustedNetworksProvider) EqualCreds(c1, c2 provider.ProviderUser) bool {
panic("not implemented")
return false
}
func New(s storage.Store) (provider.AuthProvider, error) { func New(s storage.Store) (provider.AuthProvider, error) {
hap := &TrustedNetworksProvider{ hap := &TrustedNetworksProvider{
AuthProviderBase: provider.AuthProviderBase{ AuthProviderBase: provider.AuthProviderBase{
@ -78,8 +61,19 @@ func (hap *TrustedNetworksProvider) NewCredData() interface{} {
return &UserData{} return &UserData{}
} }
func (hap *TrustedNetworksProvider) FlowSchema() flow.Schema { func (hap *TrustedNetworksProvider) FlowSchema() []provider.FlowSchemaItem {
return nil return []provider.FlowSchemaItem{
{
Type: "string",
Name: "username",
Required: true,
},
{
Type: "string",
Name: "password",
Required: true,
},
}
} }
func init() { func init() {

View file

@ -2,102 +2,108 @@ package auth
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"time" "time"
"github.com/golang-jwt/jwt"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"dynatron.me/x/blasphem/internal/common" "dynatron.me/x/blasphem/internal/common"
"dynatron.me/x/blasphem/internal/generate" "dynatron.me/x/blasphem/internal/generate"
"dynatron.me/x/blasphem/pkg/auth/provider" "dynatron.me/x/blasphem/pkg/auth/provider"
) )
type authCodeStore struct { type (
s map[authCodeTuple]flowResult TokenType string
RefreshTokenID string
)
type RefreshToken struct {
ID RefreshTokenID `json:"id"`
UserID UserID `json:"user_id"`
ClientID *ClientID `json:"client_id"`
ClientName *string `json:"client_name"`
ClientIcon *string `json:"client_icon"`
TokenType TokenType `json:"token_type"`
CreatedAt *common.PyTimestamp `json:"created_at"`
AccessTokenExpiration json.Number `json:"access_token_expiration"`
Token string `json:"token"`
JWTKey string `json:"jwt_key"`
LastUsedAt *common.PyTimestamp `json:"last_used_at"`
LastUsedIP *string `json:"last_used_ip"`
CredentialID *CredID `json:"credential_id"`
Version *string `json:"version"`
}
type AccessSessionStore struct {
s map[AccessTokenID]*AccessToken
lastCull time.Time lastCull time.Time
} }
type authCodeTuple struct { type AccessTokenID string
ClientID ClientID
Code AuthCode
}
func (t *authCodeTuple) IsValid() bool { func (t *AccessTokenID) IsValid() bool {
// TODO: more validation than this // TODO: more validation than this
return t.Code != "" return *t != ""
} }
type flowResult struct { type AccessToken struct { // TODO: jwt bro
Time time.Time ID AccessTokenID
Cred *Credentials Ctime time.Time
Expires time.Time
Addr string
user provider.ProviderUser `json:"-"`
} }
// OAuth 4.2.1 spec recommends 10 minutes func (ss *AccessSessionStore) init() {
const authCodeExpire = 10 * time.Minute ss.s = make(map[AccessTokenID]*AccessToken)
func (f *flowResult) IsValid(now time.Time) bool {
if now.After(f.Time.Add(authCodeExpire)) {
return false
}
return true
}
func (ss *authCodeStore) init() {
ss.s = make(map[authCodeTuple]flowResult)
} }
const cullInterval = 5 * time.Minute const cullInterval = 5 * time.Minute
func (ss *authCodeStore) cull() { func (ss *AccessSessionStore) cull() {
if now := time.Now(); now.Sub(ss.lastCull) > cullInterval { if now := time.Now(); now.Sub(ss.lastCull) > cullInterval {
for k, v := range ss.s { for k, v := range ss.s {
if !v.IsValid(now) { if now.After(v.Expires) {
delete(ss.s, k) delete(ss.s, k)
} }
} }
} }
} }
func (ss *authCodeStore) put(clientID ClientID, cred *Credentials) string { func (ss *AccessSessionStore) register(t *AccessToken) {
ss.cull() ss.cull()
code := generate.UUID() ss.s[t.ID] = t
ss.s[authCodeTuple{clientID, AuthCode(code)}] = flowResult{Time: time.Now(), Cred: cred}
return code
} }
func (ss *authCodeStore) get(tr *TokenRequest) (*Credentials, bool) { func (ss *AccessSessionStore) verify(tr *TokenRequest, r *http.Request) (provider.ProviderUser, bool) {
key := authCodeTuple{tr.ClientID, tr.Code} if t, hasToken := ss.s[tr.Code]; hasToken {
if t, hasCode := ss.s[key]; hasCode { // TODO: JWT
defer delete(ss.s, key) if t.Expires.After(time.Now()) {
if t.IsValid(time.Now()) { return t.user, true
return t.Cred, true } else {
delete(ss.s, t.ID)
} }
} }
return nil, false return nil, false
} }
type Credentials struct { type Credential struct {
ID CredID `json:"id"` ID CredID `json:"id"`
UserID UserID `json:"user_id"` UserID UserID `json:"user_id"`
AuthProviderType string `json:"auth_provider_type"` AuthProviderType string `json:"auth_provider_type"`
AuthProviderID *string `json:"auth_provider_id"` AuthProviderID *string `json:"auth_provider_id"`
DataRaw *json.RawMessage `json:"data,omitempty"` DataRaw *json.RawMessage `json:"data,omitempty"`
user provider.ProviderUser `json:"-"`
User provider.ProviderUser `json:"-"`
} }
func (cred *Credentials) MarshalJSON() ([]byte, error) { func (cred *Credential) MarshalJSON() ([]byte, error) {
type CredAlias Credentials // alias so ø method set and we don't recurse type CredAlias Credential // alias so ø method set and we don't recurse
nCd := (*CredAlias)(cred) nCd := (*CredAlias)(cred)
if cred.User != nil { if cred.user != nil {
providerData := cred.User.UserData() providerData := cred.user.UserData()
if providerData != nil { if providerData != nil {
b, err := json.Marshal(providerData) b, err := json.Marshal(providerData)
if err != nil { if err != nil {
@ -112,238 +118,58 @@ func (cred *Credentials) MarshalJSON() ([]byte, error) {
return json.Marshal(nCd) return json.Marshal(nCd)
} }
type ( func (a *Authenticator) verifyAndGetCredential(tr *TokenRequest, r *http.Request) *Credential {
TokenType string user, success := a.sessions.verify(tr, r)
RefreshTokenID string
RefreshTokenToken string
)
func (rti RefreshTokenID) String() string { return string(rti) }
func (rti RefreshTokenToken) IsValid() bool { return rti != "" }
const (
TokenTypeSystem TokenType = "system"
TokenTypeNormal TokenType = "normal"
TokenTypeLongLived TokenType = "long_lived_access_token"
TokenTypeNone TokenType = ""
)
func (tt TokenType) IsValid() bool {
switch tt {
case TokenTypeSystem, TokenTypeNormal, TokenTypeLongLived:
return true
}
return false
}
type RefreshToken struct {
ID RefreshTokenID `json:"id"`
UserID UserID `json:"user_id"`
ClientID *ClientID `json:"client_id"`
ClientName *string `json:"client_name"`
ClientIcon *string `json:"client_icon"`
TokenType TokenType `json:"token_type"`
CreatedAt *common.PyTimestamp `json:"created_at"`
AccessTokenExpiration json.Number `json:"access_token_expiration"`
Token RefreshTokenToken `json:"token"`
JWTKey string `json:"jwt_key"`
LastUsedAt *common.PyTimestamp `json:"last_used_at"`
LastUsedIP *string `json:"last_used_ip"`
CredentialID *CredID `json:"credential_id"`
Version *string `json:"version"`
User *User `json:"-"`
}
func (rt *RefreshToken) IsValid() bool {
return rt.JWTKey != ""
}
func (rt *RefreshToken) AccessExpiration() (exp int64) {
exp, err := rt.AccessTokenExpiration.Int64()
if err != nil {
panic(err)
}
return
}
type RefreshOption func(*RefreshToken)
func WithClientID(cid ClientID) RefreshOption {
return func(rt *RefreshToken) {
rt.ClientID = &cid
}
}
func WithClientName(n string) RefreshOption {
return func(rt *RefreshToken) {
rt.ClientName = &n
}
}
func WithClientIcon(n string) RefreshOption {
return func(rt *RefreshToken) {
rt.ClientIcon = &n
}
}
func WithTokenType(t TokenType) RefreshOption {
return func(rt *RefreshToken) {
rt.TokenType = t
}
}
func WithCredential(c *Credentials) RefreshOption {
return func(rt *RefreshToken) {
rt.CredentialID = &c.ID
}
}
const DefaultAccessExpiration = "1800" // json 🤮
func (a *authenticator) NewRefreshToken(user *User, opts ...RefreshOption) (*RefreshToken, error) {
e := func(es string, arg ...interface{}) (*RefreshToken, error) {
return nil, fmt.Errorf(es, arg...)
}
now := common.PyTimestamp(time.Now())
r := &RefreshToken{
ID: RefreshTokenID(generate.UUID()),
UserID: user.ID,
Token: RefreshTokenToken(generate.Hex(64)),
JWTKey: generate.Hex(64),
CreatedAt: &now,
AccessTokenExpiration: DefaultAccessExpiration,
User: user,
}
for _, opt := range opts {
opt(r)
}
if r.TokenType == TokenTypeNone {
if user.SystemGenerated {
r.TokenType = TokenTypeSystem
} else {
r.TokenType = TokenTypeNormal
}
}
switch {
case !r.TokenType.IsValid():
return e("invalid token type")
case !user.Active:
return e("user is not active")
case user.SystemGenerated && r.ClientID != nil:
return e("system generated users cannot have refresh tokens connected to a client")
case !r.TokenType.IsValid():
return e("invalid token type '%v'", r.TokenType)
case user.SystemGenerated != (r.TokenType == TokenTypeSystem):
return e("system generated user can only have system type refresh tokens")
case r.TokenType == TokenTypeNormal && r.ClientID == nil:
return e("client is required to generate a refresh token")
case r.TokenType == TokenTypeLongLived && r.ClientName == nil:
return e("client name is required for long-lived token")
}
if r.TokenType == TokenTypeLongLived {
for _, lv := range user.RefreshTokens {
if strPtrEq(lv.ClientName, r.ClientName) && lv.TokenType == TokenTypeLongLived {
return e("client name '%v' already exists", *r.ClientName)
}
}
}
return a.store.PutRefreshToken(r)
}
func (r *RefreshToken) AccessToken(req *http.Request) (string, error) {
now := time.Now()
pytnow := common.PyTimestamp(now)
r.LastUsedAt = &pytnow
r.LastUsedIP = &req.RemoteAddr
return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{
Issuer: r.ID.String(),
IssuedAt: now.Unix(),
ExpiresAt: now.Add(time.Duration(r.AccessExpiration()) * time.Second).Unix(),
}).SignedString([]byte(r.JWTKey))
}
func (a *authenticator) ValidateAccessToken(token AccessToken) *RefreshToken {
var unverifiedIssRT *RefreshToken
claims := &jwt.StandardClaims{}
_, err := jwt.ParseWithClaims(string(token), claims, func(jt *jwt.Token) (interface{}, error) {
iss := jt.Claims.(*jwt.StandardClaims).Issuer
unverifiedIssRT = a.store.GetRefreshToken(RefreshTokenID(iss))
if unverifiedIssRT == nil {
return nil, fmt.Errorf("bad token")
}
return []byte(unverifiedIssRT.JWTKey), nil
})
if err != nil {
log.Error().Err(err).Msg("validateAccessToken")
return nil
}
return unverifiedIssRT
}
func (a *authenticator) verifyAndGetCredential(tr *TokenRequest) *Credentials {
cred, success := a.authCodes.get(tr)
if !success { if !success {
return nil return nil
} }
cred := a.store.Credential(user)
return cred return cred
} }
const defaultExpiration = 15 * time.Minute const defaultExpiration = 15 * time.Minute
func (a *authenticator) NewAuthCode(clientID ClientID, cred *Credentials) string { func (a *Authenticator) NewAccessToken(r *http.Request, user provider.ProviderUser, f *Flow) AccessTokenID {
return a.authCodes.put(clientID, cred) id := AccessTokenID(generate.UUID())
now := time.Now()
t := &AccessToken{
ID: id,
Ctime: now,
Expires: now.Add(defaultExpiration),
Addr: r.RemoteAddr,
user: user,
}
a.sessions.register(t)
return id
} }
type GrantType string type GrantType string
const ( const (
GrantAuthCode GrantType = "authorization_code" GTAuthorizationCode GrantType = "authorization_code"
GrantRefreshToken GrantType = "refresh_token" GTRefreshToken GrantType = "refresh_token"
) )
type ClientID common.ClientID type ClientID string
func (c *ClientID) IsValid() bool { func (c *ClientID) IsValid() bool {
// TODO: || !indieauth.VerifyClientID(rq.ClientID)? // TODO: || !indieauth.VerifyClientID(rq.ClientID)?
return *c != "" return *c != ""
} }
type AuthCode string
func (ac *AuthCode) IsValid() bool {
return *ac != ""
}
type TokenRequest struct { type TokenRequest struct {
ClientID ClientID `form:"client_id"` ClientID ClientID `form:"client_id"`
Code AuthCode `form:"code"` Code AccessTokenID `form:"code"`
GrantType GrantType `form:"grant_type"` GrantType GrantType `form:"grant_type"`
RefreshToken RefreshTokenToken `form:"refresh_token"`
} }
const AuthFailed = "authentication failure" func (a *Authenticator) TokenHandler(c echo.Context) error {
func (a *authenticator) TokenHandler(c echo.Context) error {
a.Lock()
defer a.Unlock()
rq := new(TokenRequest) rq := new(TokenRequest)
err := c.Bind(rq) err := c.Bind(rq)
if err != nil { if err != nil {
@ -351,7 +177,7 @@ func (a *authenticator) TokenHandler(c echo.Context) error {
} }
switch rq.GrantType { switch rq.GrantType {
case GrantAuthCode: case GTAuthorizationCode:
if !rq.ClientID.IsValid() { if !rq.ClientID.IsValid() {
return c.JSON(http.StatusBadRequest, AuthError{Error: "invalid_request", Description: "invalid client ID"}) return c.JSON(http.StatusBadRequest, AuthError{Error: "invalid_request", Description: "invalid client ID"})
} }
@ -360,88 +186,21 @@ func (a *authenticator) TokenHandler(c echo.Context) error {
return c.JSON(http.StatusBadRequest, AuthError{Error: "invalid_request", Description: "invalid code"}) return c.JSON(http.StatusBadRequest, AuthError{Error: "invalid_request", Description: "invalid code"})
} }
cred := a.verifyAndGetCredential(rq) if cred := a.verifyAndGetCredential(rq, c.Request()); cred != nil {
if cred == nil { // TODO: success
return c.JSON(http.StatusBadRequest, AuthError{Error: "invalid_request", Description: "invalid code"}) user, err := a.getOrCreateUser(cred)
if err != nil {
return c.JSON(http.StatusUnauthorized, AuthError{Error: "access_denied", Description: err.Error()})
}
if err := user.allowedToAuth(); err != nil {
return c.JSON(http.StatusUnauthorized, AuthError{Error: "access_denied", Description: err.Error()})
}
return c.String(http.StatusOK, "token good I guess")
} }
case GTRefreshToken:
user, err := a.getOrCreateUser(cred) return c.String(http.StatusNotImplemented, "not implemented")
if err != nil {
log.Error().Err(err).Msg("getOrCreateUser")
return c.JSON(http.StatusForbidden, AuthError{Error: "access_denied", Description: AuthFailed})
}
if err := user.allowedToAuth(c.Request()); err != nil {
log.Error().Err(err).Msg("allowedToAuth")
return c.JSON(http.StatusForbidden, AuthError{Error: "access_denied", Description: AuthFailed})
}
rt, err := a.NewRefreshToken(user, WithClientID(rq.ClientID), WithCredential(cred))
if err != nil {
log.Error().Err(err).Msg("NewRefreshToken")
return c.JSON(http.StatusForbidden, AuthError{Error: "access_denied", Description: AuthFailed})
}
at, err := rt.AccessToken(c.Request())
if err != nil {
log.Error().Err(err).Msg("AccessToken")
return c.JSON(http.StatusForbidden, AuthError{Error: "access_denied", Description: AuthFailed})
}
return common.NoCache(c).JSON(http.StatusOK, &struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
RefreshToken RefreshTokenToken `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
HAAuthProvider string `json:"ha_auth_provider"`
}{
AccessToken: at,
TokenType: "Bearer",
RefreshToken: rt.Token,
ExpiresIn: rt.AccessExpiration(),
HAAuthProvider: cred.AuthProviderType,
})
case GrantRefreshToken:
log.Debug().Interface("request", c.Request()).Interface("tokenRequest", rq).Msg("grant_type=refresh_token")
if !rq.ClientID.IsValid() {
return c.JSON(http.StatusBadRequest, AuthError{Error: "invalid_request", Description: "invalid client ID"})
}
if !rq.RefreshToken.IsValid() {
return c.JSON(http.StatusBadRequest, AuthError{Error: "invalid_request"})
}
rt := a.store.GetRefreshTokenByToken(rq.RefreshToken)
if rt == nil {
return c.JSON(http.StatusBadRequest, AuthError{Error: "invalid_grant"})
}
if rt.ClientID == nil || *rt.ClientID != rq.ClientID {
return c.JSON(http.StatusBadRequest, AuthError{Error: "invalid_request"})
}
if err := rt.User.allowedToAuth(c.Request()); err != nil {
return c.JSON(http.StatusForbidden, AuthError{Error: "access_denied", Description: err.Error()})
}
at, err := rt.AccessToken(c.Request())
if err != nil {
return c.JSON(http.StatusForbidden, AuthError{Error: "access_denied", Description: err.Error()})
}
return common.NoCache(c).JSON(http.StatusOK, &struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
}{
AccessToken: at,
TokenType: "Bearer",
ExpiresIn: rt.AccessExpiration(),
})
} }
return c.JSON(http.StatusBadRequest, AuthError{Error: "invalid_request"}) return c.String(http.StatusUnauthorized, "token bad I guess")
} }
type AccessToken string

View file

@ -1,15 +1,11 @@
package auth package auth
import ( import (
"crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"dynatron.me/x/blasphem/internal/generate"
"dynatron.me/x/blasphem/pkg/auth/provider" "dynatron.me/x/blasphem/pkg/auth/provider"
"dynatron.me/x/blasphem/pkg/storage" "dynatron.me/x/blasphem/pkg/storage"
"github.com/rs/zerolog/log"
) )
const ( const (
@ -18,127 +14,37 @@ const (
type AuthStore interface { type AuthStore interface {
User(UserID) *User User(UserID) *User
GetCredential(provider.ProviderUser) *Credentials Credential(provider.ProviderUser) *Credential
PutRefreshToken(*RefreshToken) (*RefreshToken, error)
GetRefreshTokenByToken(token RefreshTokenToken) *RefreshToken
GetRefreshToken(RefreshTokenID) *RefreshToken
} }
type authStore struct { type authStore struct {
storage.Item `json:"-"` Users []User `json:"users"`
Groups []Group `json:"groups"`
Credentials []Credential `json:"credentials"`
Refresh []RefreshToken `json:"refresh_tokens"`
Users []*User `json:"users"` userMap map[UserID]*User
Groups []*Group `json:"groups"` providerUsers map[provider.ProviderUser]*Credential
Credentials []*Credentials `json:"credentials"`
Refresh []*RefreshToken `json:"refresh_tokens"`
userMap map[UserID]*User
providerUsers map[provider.ProviderUser]*Credentials
store storage.Store
} }
func (as *authStore) sync() { func (as *authStore) Credential(p provider.ProviderUser) *Credential {
err := as.store.Flush(as.ItemKey()) c, have := as.providerUsers[p]
if err != nil { if !have {
log.Error().Err(err).Msg("sync authStore") return nil
}
}
func strPtrEq(n1, n2 *string) bool {
return (n1 == n2 || (n1 != nil && n2 != nil && *n1 == *n2))
}
func (as *authStore) GetCredential(p provider.ProviderUser) *Credentials {
var found *Credentials
for _, cr := range as.Credentials {
if p != nil && (p == cr.User ||
(p.Provider() != nil &&
strPtrEq(cr.AuthProviderID, p.Provider().ProviderID()) &&
cr.AuthProviderType == p.Provider().ProviderType() &&
p.Provider().EqualCreds(cr.User.UserData(), p.UserData()))) {
found = cr
}
}
return found
}
func (as *authStore) PutRefreshToken(rt *RefreshToken) (*RefreshToken, error) {
e := func(es string, a ...interface{}) (*RefreshToken, error) {
return nil, fmt.Errorf(es, a...)
}
u, hasUser := as.userMap[rt.UserID]
if !hasUser {
return e("no such user %v", rt.UserID)
}
as.Refresh = append(as.Refresh, rt)
u.RefreshTokens = append(u.RefreshTokens, rt)
as.sync()
return rt, nil
}
func (as *authStore) GetRefreshTokenByToken(token RefreshTokenToken) *RefreshToken {
var found *RefreshToken
for _, u := range as.Users {
for _, rt := range u.RefreshTokens {
if subtle.ConstantTimeCompare([]byte(token), []byte(rt.Token)) == 1 {
found = rt
found.User = u
}
}
}
return found
}
func (as *authStore) GetRefreshToken(tid RefreshTokenID) *RefreshToken {
var found *RefreshToken
for _, u := range as.Users {
for _, rt := range u.RefreshTokens {
if subtle.ConstantTimeCompare([]byte(tid), []byte(rt.ID.String())) == 1 {
found = rt
found.User = u
}
}
}
return found
}
func (as *authStore) newCredential(p provider.ProviderUser) *Credentials {
// XXX: probably broken
prov := p.Provider()
id := generate.UUID()
c := &Credentials{
ID: CredID(id),
AuthProviderType: prov.ProviderBase().Type,
AuthProviderID: prov.ProviderBase().ID,
} }
return c return c
} }
func (a *authenticator) newAuthStore(s storage.Store) (as *authStore, err error) { func (a *Authenticator) newAuthStore(s storage.Store) (as *authStore, err error) {
as = &authStore{ as = &authStore{}
store: s, err = s.Get(AuthStoreKey, as)
}
as.Item, err = s.GetItem(AuthStoreKey, as)
if err != nil {
return
}
as.userMap = make(map[UserID]*User) as.userMap = make(map[UserID]*User)
as.providerUsers = make(map[provider.ProviderUser]*Credentials) as.providerUsers = make(map[provider.ProviderUser]*Credential)
for _, u := range as.Users { for _, u := range as.Users {
as.userMap[u.ID] = u as.userMap[u.ID] = &u
} }
for _, c := range as.Credentials { for _, c := range as.Credentials {
@ -155,45 +61,14 @@ func (a *authenticator) newAuthStore(s storage.Store) (as *authStore, err error)
return nil, err return nil, err
} }
c.User = prov.Lookup(pd.(provider.ProviderUser)) c.user = prov.Lookup(pd.(provider.ProviderUser))
if c.User == nil { if c.user == nil {
return nil, fmt.Errorf("cannot find user in provider %s", prov.ProviderName()) return nil, fmt.Errorf("cannot find user in provider %s", prov.ProviderName())
} }
as.providerUsers[c.User] = c as.providerUsers[c.user] = &c
}
u, hasUser := as.userMap[c.UserID]
if !hasUser {
log.Error().Str("userid", string(c.UserID)).Msg("creds no such userid in map")
continue
}
u.Creds = append(u.Creds, c)
}
// remove invalid RefreshTokens
i := 0
for _, rt := range as.Refresh {
if rt.IsValid() {
u, hasUser := as.userMap[rt.UserID]
if !hasUser {
log.Error().Str("userid", string(rt.UserID)).Msg("refreshtokens no such userid in map")
continue
}
as.Refresh[i] = rt
i++
u.RefreshTokens = append(u.RefreshTokens, rt)
} }
} }
// don't leak memory
for j := i; j < len(as.Refresh); j++ {
as.Refresh[j] = nil
}
as.Refresh = as.Refresh[:i]
return return
} }

View file

@ -1,8 +1,7 @@
package auth package auth
import ( import (
"net" "github.com/rs/zerolog/log"
"net/http"
) )
type UserID string type UserID string
@ -19,9 +18,6 @@ type User struct {
GroupIDs []GroupID `json:"group_ids"` GroupIDs []GroupID `json:"group_ids"`
Data interface{} `json:"data,omitempty"` Data interface{} `json:"data,omitempty"`
UserMetadata UserMetadata
Creds []*Credentials `json:"-"`
RefreshTokens []*RefreshToken `json:"-"`
} }
type UserMetadata struct { type UserMetadata struct {
@ -32,28 +28,16 @@ type UserMetadata struct {
LocalOnly bool `json:"local_only"` LocalOnly bool `json:"local_only"`
} }
func (u *User) allowedToAuth(r *http.Request) error { func (u *User) allowedToAuth() error {
if !u.Active { if !u.Active {
return ErrDisabled return ErrDisabled
} }
if !u.LocalOnly { return nil
return nil
}
ip := net.ParseIP(r.RemoteAddr)
if ip == nil {
return ErrInvalidIP
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
return nil
}
return ErrUserAuthRemote
} }
func (a *authenticator) getOrCreateUser(c *Credentials) (*User, error) { func (a *Authenticator) getOrCreateUser(c *Credential) (*User, error) {
log.Debug().Interface("userdata", c).Msg("getOrCreateUser")
u := a.store.User(c.UserID) u := a.store.User(c.UserID)
if u == nil { if u == nil {
return nil, ErrInvalidAuth return nil, ErrInvalidAuth

View file

@ -7,36 +7,20 @@ import (
"strings" "strings"
"dynatron.me/x/blasphem/internal/common" "dynatron.me/x/blasphem/internal/common"
"dynatron.me/x/blasphem/pkg/auth"
"dynatron.me/x/blasphem/pkg/blas/core"
"dynatron.me/x/blasphem/pkg/bus" "dynatron.me/x/blasphem/pkg/bus"
"dynatron.me/x/blasphem/pkg/components"
"dynatron.me/x/blasphem/pkg/config" "dynatron.me/x/blasphem/pkg/config"
"dynatron.me/x/blasphem/pkg/storage" "dynatron.me/x/blasphem/pkg/storage"
"dynatron.me/x/blasphem/pkg/wsapi"
"github.com/rs/zerolog/log"
) )
type Blas struct { type Blas struct {
bus.Bus *bus.Bus
storage.Store storage.Store
auth.Authenticator
Config *config.Config Config *config.Config
core.WebSocketManager
components components.ComponentStore
} }
func (b *Blas) Version() string { func (b *Blas) Shutdown(ctx context.Context) error {
return common.Version b.Bus.Shutdown()
} b.Store.Shutdown()
func (b *Blas) Conf() *config.Config { return b.Config }
func (b *Blas) ShutdownBlas(ctx context.Context) error {
b.Bus.ShutdownBus()
b.Store.ShutdownStore()
return ctx.Err() return ctx.Err()
} }
@ -67,42 +51,12 @@ func (b *Blas) openStore() error {
return err return err
} }
func (b *Blas) Component(k components.ComponentKey) components.Component { func New(cfg *config.Config) (*Blas, error) {
c, ok := b.components[k] b := &Blas{
if !ok { Bus: bus.New(),
return nil Config: cfg,
}
return c
}
func (b *Blas) Components() components.ComponentStore { return b.components }
func New(cfg *config.Config) (b *Blas, err error) {
b = &Blas{
Bus: bus.New(),
Config: cfg,
components: make(components.ComponentStore),
WebSocketManager: wsapi.NewManager(),
}
err = b.openStore()
if err != nil {
return nil, err
}
b.Authenticator, err = auth.New(b.Store)
for k, v := range Registry {
log.Info().Msgf("Setting up component %s", k)
c, err := v(b)
if err != nil {
log.Error().Err(err).Msgf("Error setting up component %s", k)
continue
}
b.components[k] = c
} }
err := b.openStore()
return b, err return b, err
} }

View file

@ -1,21 +0,0 @@
package blas
import (
"fmt"
"dynatron.me/x/blasphem/pkg/blas/core"
"dynatron.me/x/blasphem/pkg/components"
)
type Setup func(core.Blas) (components.Component, error)
var Registry = make(map[components.ComponentKey]Setup)
func Register(key components.ComponentKey, c Setup) {
_, already := Registry[key]
if already {
panic(fmt.Sprintf("component %s already exists", key))
}
Registry[key] = c
}

View file

@ -1,51 +0,0 @@
package core
import (
"context"
"dynatron.me/x/blasphem/pkg/auth"
"dynatron.me/x/blasphem/pkg/bus"
"dynatron.me/x/blasphem/pkg/components"
"dynatron.me/x/blasphem/pkg/config"
"dynatron.me/x/blasphem/pkg/storage"
"github.com/gorilla/websocket"
)
type Blas interface {
auth.Authenticator
bus.Bus
storage.Store
config.Configured
components.Componenter
WebSocketManager
Shutdowner
Versioner
}
type WebSocketManager interface {
// Register registers a websocket command.
// cmd is the first part, before first slash
// dataNew is a function to create a new message datatype
RegisterWSCommand(cmd string, hnd Handler, dataNew NewData)
WSCommandHandler(cmd string, splitCmd []string) (NewData, Handler, error)
}
type WebSocketSession interface {
Conn() *websocket.Conn
Go(context.Context) error
Blas() Blas
}
type Handler func(ctx context.Context, wss WebSocketSession, msgID int, cmd []string, msg interface{}) error
type NewData func(cmd []string) interface{}
type Shutdowner interface {
ShutdownBlas(context.Context) error
}
type Versioner interface {
Version() string
}

View file

@ -12,28 +12,22 @@ type (
listeners []chan<- Event listeners []chan<- Event
bus struct { Bus struct {
sync.RWMutex sync.RWMutex
subs map[string]listeners subs map[string]listeners
} }
Bus interface {
Sub(topic string, ch chan<- Event)
Unsub(topic string, ch chan<- Event)
Pub(topic string, data interface{})
ShutdownBus()
}
) )
func New() Bus { func New() *Bus {
bus := &bus{ bus := &Bus{
subs: make(map[string]listeners), subs: make(map[string]listeners),
} }
return bus return bus
} }
func (b *bus) Sub(topic string, ch chan<- Event) { func (b *Bus) Sub(topic string, ch chan<- Event) {
b.Lock() b.Lock()
defer b.Unlock() defer b.Unlock()
@ -44,7 +38,7 @@ func (b *bus) Sub(topic string, ch chan<- Event) {
} }
} }
func (b *bus) Unsub(topic string, ch chan<- Event) { func (b *Bus) Unsub(topic string, ch chan<- Event) {
b.Lock() b.Lock()
defer b.Unlock() defer b.Unlock()
@ -57,7 +51,7 @@ func (b *bus) Unsub(topic string, ch chan<- Event) {
} }
} }
func (b *bus) Pub(topic string, data interface{}) { func (b *Bus) Pub(topic string, data interface{}) {
b.RLock() b.RLock()
defer b.RUnlock() defer b.RUnlock()
@ -71,7 +65,7 @@ func (b *bus) Pub(topic string, data interface{}) {
} }
} }
func (b *bus) ShutdownBus() { func (b *Bus) Shutdown() {
for _, v := range b.subs { for _, v := range b.subs {
for _, c := range v { for _, c := range v {
close(c) close(c)

View file

@ -2,18 +2,18 @@ package serve
import ( import (
"dynatron.me/x/blasphem/internal/common" "dynatron.me/x/blasphem/internal/common"
blas "dynatron.me/x/blasphem/pkg/blas/core" "dynatron.me/x/blasphem/pkg/config"
"dynatron.me/x/blasphem/pkg/server" "dynatron.me/x/blasphem/pkg/server"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
type ServeOptions struct { type ServeOptions struct {
core blas.Blas cfg *config.Config
} }
func Command(core blas.Blas) *cobra.Command { func Command(cfg *config.Config) *cobra.Command {
opts := makeOptions(core) opts := makeOptions(cfg)
serveCmd := &cobra.Command{ serveCmd := &cobra.Command{
Use: "serve", Use: "serve",
Short: "starts the " + common.AppName + " server", Short: "starts the " + common.AppName + " server",
@ -23,9 +23,9 @@ func Command(core blas.Blas) *cobra.Command {
return serveCmd return serveCmd
} }
func makeOptions(core blas.Blas) *ServeOptions { func makeOptions(cfg *config.Config) *ServeOptions {
return &ServeOptions{ return &ServeOptions{
core: core, cfg: cfg,
} }
} }
@ -34,7 +34,7 @@ func (o *ServeOptions) Options(_ *cobra.Command, args []string) error {
} }
func (o *ServeOptions) Execute() error { func (o *ServeOptions) Execute() error {
server, err := server.New(o.core) server, err := server.New(o.cfg)
if err != nil { if err != nil {
return err return err
} }

View file

@ -1,17 +0,0 @@
package components
import ()
type Componenter interface {
Component(ComponentKey) Component
Components() ComponentStore
}
type (
ComponentStore map[ComponentKey]Component
ComponentKey string
Component interface {
Shutdown()
}
)

View file

@ -11,10 +11,6 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type Configured interface {
Conf() *Config
}
type Config struct { type Config struct {
DataDir *string `yaml:"data_dir,omitempty"` DataDir *string `yaml:"data_dir,omitempty"`
Server *server.Config `yaml:"server"` Server *server.Config `yaml:"server"`

View file

@ -1,203 +0,0 @@
// flow is the data entry flow.
package flow
import (
"fmt"
"time"
"dynatron.me/x/blasphem/internal/generate"
)
type ResultType string
type FlowID string
type Step string
type HandlerKey string
type Errors interface{}
type FlowStore map[FlowID]Handler
type FlowManager struct {
flows FlowStore
}
type Result struct {
Type ResultType `json:"type"`
ID FlowID `json:"flow_id"`
Handler []*HandlerKey `json:"handler"`
Title *string `json:"title,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
StepID *Step `json:"step_id,omitempty"`
Schema []SchemaItem `json:"data_schema"`
Extra *string `json:"extra,omitempty"`
Required *bool `json:"required,omitempty"`
Errors interface{} `json:"errors"`
Description *string `json:"description,omitempty"`
DescPlace *string `json:"description_placeholders"`
URL *string `json:"url,omitempty"`
Reason *string `json:"reason,omitempty"`
Context *string `json:"context,omitempty"`
Result interface{} `json:"result,omitempty"`
LastStep *string `json:"last_step"`
Options map[string]interface{} `json:"options,omitempty"`
Version *int `json:"version,omitempty"`
}
type Handler interface {
BaseHandler() FlowHandler
FlowID() FlowID
flowCtime() time.Time
}
const (
StepInit Step = "init"
)
func (fs Schema) CheckRequired(rm map[string]interface{}) error {
for _, si := range fs {
if si.Required {
if _, ok := rm[si.Name]; !ok {
return fmt.Errorf("missing required param %s", si.Name)
}
}
}
return nil
}
func NewFlowManager() *FlowManager {
return &FlowManager{
flows: make(FlowStore),
}
}
func stepPtr(s Step) *Step { return &s }
type FlowHandler struct {
ID FlowID // ID is the FlowID
Handler HandlerKey // Handler key
// curStep is the current step set by the flow manager
curStep Step
ctime time.Time
}
func (f *FlowHandler) Step() Step { return f.curStep }
func (f *FlowHandler) BaseHandler() FlowHandler { return *f }
func (f *FlowHandler) FlowID() FlowID {
return f.ID
}
func (f *FlowHandler) flowCtime() time.Time { return f.ctime }
func NewFlowHandlerBase(hand string) FlowHandler {
return FlowHandler{
ID: FlowID(generate.UUID()),
Handler: HandlerKey(hand),
curStep: StepInit,
ctime: time.Now(),
}
}
func (hk *HandlerKey) String() string {
return string(*hk)
}
func (fm *FlowHandler) Handlers() []*HandlerKey {
return []*HandlerKey{&fm.Handler, nil}
}
func resultErrs(e Errors) Errors {
if e == nil {
return []string{}
}
return e
}
type FormOption func(*Result)
func (*FlowHandler) WithErrors(e Errors) FormOption {
return func(r *Result) {
r.Errors = e
}
}
func (*FlowHandler) WithStep(s Step) FormOption {
return func(r *Result) {
r.StepID = stepPtr(s)
}
}
func (*FlowHandler) WithSchema(sch Schemer) FormOption {
return func(r *Result) {
r.Schema = sch.FlowSchema()
}
}
func (fm *FlowHandler) ShowForm(opts ...FormOption) *Result {
res := &Result{
Type: TypeForm,
ID: fm.ID,
StepID: stepPtr(fm.curStep),
Handler: fm.Handlers(),
}
for _, opt := range opts {
opt(res)
}
res.Errors = resultErrs(res.Errors)
return res
}
func (fm *FlowManager) Delete(id FlowID) {
delete(fm.flows, id)
}
const (
TypeForm ResultType = "form"
TypeCreateEntry ResultType = "create_entry"
TypeAbort ResultType = "abort"
TypeExternalStep ResultType = "external"
TypeExternalStepDone ResultType = "external_done"
TypeShowProgress ResultType = "progress"
TypeShowProgressDone ResultType = "progress_done"
TypeMenu ResultType = "menu"
)
func (f *FlowHandler) touch() {
f.ctime = time.Now()
}
func (fm *FlowManager) Register(f Handler) {
fm.flows.cull()
fm.flows[f.FlowID()] = f
}
func (fs *FlowManager) Remove(f Handler) {
delete(fs.flows, f.FlowID())
}
const cullAge = time.Minute * 10
func (fs FlowStore) cull() {
for k, v := range fs {
if time.Now().Sub(v.flowCtime()) > cullAge {
delete(fs, k)
}
}
}
func (fs *FlowManager) Get(id FlowID) Handler {
f, ok := fs.flows[id]
if ok {
return f
}
return nil
}

View file

@ -1,40 +0,0 @@
package flow
type Type string
const (
TypeString Type = "string"
)
func (t Type) IsValid() bool {
switch t {
case TypeString:
return true
}
return false
}
type SchemaItem struct {
Type Type `json:"type"`
Name string `json:"name"`
Required bool `json:"required"`
}
type Schema []SchemaItem
type Schemer interface {
FlowSchema() Schema
}
func NewSchema(items ...SchemaItem) Schema {
return items
}
func RequiredString(name string) SchemaItem {
return SchemaItem{
Type: TypeString,
Name: name,
Required: true,
}
}

View file

@ -1,42 +1,24 @@
package frontend package frontend
import ( import (
"context"
"embed" "embed"
"io" "io"
"io/fs" "io/fs"
"net/http" "net/http"
"sync"
"dynatron.me/x/blasphem/pkg/blas"
"dynatron.me/x/blasphem/pkg/blas/core"
"dynatron.me/x/blasphem/pkg/components"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
const FrontendKey = "frontend"
//go:embed frontend/hass_frontend //go:embed frontend/hass_frontend
var root embed.FS var root embed.FS
type Frontend struct { var RootFS fs.FS
fsHandler echo.HandlerFunc
rootFS fs.FS
routeInstall sync.Once var FSHandler echo.HandlerFunc
}
func (fe *Frontend) InstallRoutes(e *echo.Echo) { func AliasHandler(toFile string) echo.HandlerFunc {
fe.routeInstall.Do(func() {
e.GET("/*", fe.fsHandler)
e.GET("/auth/authorize", fe.AliasHandler("authorize.html"))
})
}
func (fe *Frontend) AliasHandler(toFile string) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
file, err := fe.rootFS.Open(toFile) file, err := RootFS.Open(toFile)
if err != nil { if err != nil {
return err return err
} }
@ -51,32 +33,13 @@ func (fe *Frontend) AliasHandler(toFile string) echo.HandlerFunc {
} }
} }
func (*Frontend) Shutdown() {} func init() {
func newData(_ []string) interface{} {
return map[string]interface{}{}
}
func wsHand(ctx context.Context, wss core.WebSocketSession, msgID int, cmd []string, msg interface{}) error {
return nil
}
func Setup(b core.Blas) (components.Component, error) {
fe := &Frontend{}
var err error var err error
fe.rootFS, err = fs.Sub(root, "frontend/hass_frontend") RootFS, err = fs.Sub(root, "frontend/hass_frontend")
if err != nil { if err != nil {
return nil, err panic(err)
} }
fe.fsHandler = echo.StaticDirectoryHandler(fe.rootFS, false) FSHandler = echo.StaticDirectoryHandler(RootFS, false)
b.RegisterWSCommand("frontend", wsHand, newData)
return fe, nil
}
func init() {
blas.Register(FrontendKey, Setup)
} }

View file

@ -12,48 +12,47 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/ziflex/lecho/v3" "github.com/ziflex/lecho/v3"
"dynatron.me/x/blasphem/pkg/auth"
"dynatron.me/x/blasphem/pkg/blas" "dynatron.me/x/blasphem/pkg/blas"
"dynatron.me/x/blasphem/pkg/blas/core" "dynatron.me/x/blasphem/pkg/config"
"dynatron.me/x/blasphem/pkg/frontend" "dynatron.me/x/blasphem/pkg/frontend"
conf "dynatron.me/x/blasphem/pkg/server/config" conf "dynatron.me/x/blasphem/pkg/server/config"
) )
type Server struct { type Server struct {
core.Blas *blas.Blas
*echo.Echo *echo.Echo
auth.Authenticator
wg sync.WaitGroup wg sync.WaitGroup
} }
type RouteHaver interface {
InstallRoutes(e *echo.Echo)
}
func (s *Server) installRoutes() { func (s *Server) installRoutes() {
s.GET("/*", frontend.FSHandler)
s.GET("/api/websocket", s.wsHandler) s.GET("/api/websocket", s.wsHandler)
s.Component(frontend.FrontendKey).(RouteHaver).InstallRoutes(s.Echo) s.Authenticator.InstallRoutes(s.Echo)
s.Blas.(*blas.Blas).Authenticator.InstallRoutes(s.Echo)
for _, c := range s.Components() {
if rh, ok := c.(RouteHaver); ok {
rh.InstallRoutes(s.Echo)
}
}
} }
func New(core core.Blas) (s *Server, err error) { func New(cfg *config.Config) (s *Server, err error) {
b, err := blas.New(cfg)
if err != nil {
return nil, err
}
s = &Server{ s = &Server{
Blas: core, Blas: b,
Echo: echo.New(), Echo: echo.New(),
} }
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
logger := lecho.From(log.Logger) logger := lecho.From(log.Logger)
s.Echo.Logger = logger s.Echo.Logger = logger
cfg := s.Conf()
if cfg.Server.LogRequestErrors { if cfg.Server.LogRequestErrors {
s.Echo.Use(lecho.Middleware(lecho.Config{ s.Echo.Use(lecho.Middleware(lecho.Config{
Logger: logger, Logger: logger,
@ -81,7 +80,7 @@ func New(core core.Blas) (s *Server, err error) {
} }
func (s *Server) Shutdown(ctx context.Context) error { func (s *Server) Shutdown(ctx context.Context) error {
err := s.ShutdownBlas(ctx) err := s.Blas.Shutdown(ctx)
if err != nil { if err != nil {
return err return err
} }
@ -92,8 +91,8 @@ func (s *Server) Shutdown(ctx context.Context) error {
func (s *Server) Go() error { func (s *Server) Go() error {
s.wg.Add(1) s.wg.Add(1)
go func() { go func() {
log.Info().Str("version", s.Version()).Str("bind", s.Conf().Server.Bind).Msg("Server listening") log.Info().Str("bind", s.Config.Server.Bind).Msg("Server listening")
err := s.Start(s.Conf().Server.Bind) err := s.Start(s.Config.Server.Bind)
if err != nil && err != http.ErrServerClosed { if err != nil && err != http.ErrServerClosed {
s.Logger.Fatal(err) s.Logger.Fatal(err)
} }

View file

@ -1,12 +1,11 @@
package server package server
import ( import (
"context" "errors"
"dynatron.me/x/blasphem/pkg/wsapi" "log"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
@ -15,16 +14,7 @@ var upgrader = websocket.Upgrader{
} }
func (s *Server) wsHandler(c echo.Context) error { func (s *Server) wsHandler(c echo.Context) error {
conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) log.Println("WebSocket")
if err != nil { //conn, err := upgrader.Upgrade(w, req, nil)
return err return errors.New("not handled")
}
defer conn.Close()
_ = log.Debug
ctx, cancel := context.WithCancel(c.Request().Context())
defer cancel()
return wsapi.NewSession(s, c, conn).Go(ctx)
} }

View file

@ -129,7 +129,7 @@ func (s *fsStore) FlushAll() []error {
return errs return errs
} }
func (s *fsStore) ShutdownStore() { func (s *fsStore) Shutdown() {
errs := s.FlushAll() errs := s.FlushAll()
if errs != nil { if errs != nil {
log.Error().Errs("errors", errs).Msg("errors persisting store") log.Error().Errs("errors", errs).Msg("errors persisting store")

View file

@ -50,6 +50,6 @@ type Store interface {
// Flush flushes a single key to backing. // Flush flushes a single key to backing.
Flush(key string) error Flush(key string) error
// ShutdownStore is called to quiesce and shutdown the store. // Shutdown is called to quiesce and shutdown the store.
ShutdownStore() Shutdown()
} }

View file

@ -1,264 +0,0 @@
package wsapi
import (
"context"
"encoding/json"
"errors"
"strings"
"dynatron.me/x/blasphem/pkg/auth"
"dynatron.me/x/blasphem/pkg/blas/core"
"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
"github.com/mitchellh/mapstructure"
"github.com/rs/zerolog/log"
)
var (
NoSuchHandlerErr = errors.New("bad websocket command")
NoMessageIDErr = errors.New("no message ID")
AuthInvalidErr = errors.New("invalid auth")
)
type Type string
type MsgBase struct {
ID *int `json:"id,omitempty"`
Type Type `json:"type"`
}
type (
wsSession struct {
conn *websocket.Conn
b core.Blas
ec echo.Context
write chan<- interface{}
user *auth.User
refreshToken *auth.RefreshToken
}
wsEntry struct {
dataNew core.NewData
hnd core.Handler
}
wsRegistry map[string]wsEntry
wsManager struct {
r wsRegistry
}
)
func (wsm *wsManager) RegisterWSCommand(cmd string, hnd core.Handler, dataNew core.NewData) {
wsm.r[cmd] = wsEntry{
dataNew: dataNew,
hnd: hnd,
}
}
func (wsm *wsManager) WSCommandHandler(cmd string, cmdSplit []string) (core.NewData, core.Handler, error) {
if wse, ok := wsm.r[cmd]; ok {
return wse.dataNew, wse.hnd, nil
}
if wse, ok := wsm.r[cmdSplit[0]]; ok {
return wse.dataNew, wse.hnd, nil
}
return nil, nil, NoSuchHandlerErr
}
func NewManager() core.WebSocketManager {
return &wsManager{
r: make(wsRegistry),
}
}
func NewSession(s core.Blas, c echo.Context, conn *websocket.Conn) core.WebSocketSession {
ws := &wsSession{
conn: conn,
b: s,
ec: c,
}
return ws
}
func (ws *wsSession) Conn() *websocket.Conn {
return ws.conn
}
func (ws *wsSession) Blas() core.Blas { return ws.b }
func (ws *wsSession) Go(ctx context.Context) error {
authP := &authPhase{ws}
err := ws.sendAuthRequired()
if err != nil {
return err
}
_, rdr, err := ws.conn.NextReader()
if err != nil {
return err
}
err = authP.handleMsg(ctx, rdr)
if err != nil || ws.refreshToken == nil {
return err
}
// command phase
msgChan := make(chan map[string]interface{})
write := make(chan interface{})
ws.write = write
defer close(write)
cCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func(ctx context.Context, ch chan<- map[string]interface{}) {
defer close(ch)
for {
if err := ctx.Err(); err != nil {
return
}
_, rdr, err := ws.conn.NextReader()
switch {
case err == nil:
case websocket.IsCloseError(err, websocket.CloseGoingAway):
return
case err != nil:
log.Error().Err(err).Str("remote", ws.ec.Request().RemoteAddr).Msg("websocket read fail")
return
}
var msgMap map[string]interface{}
err = json.NewDecoder(rdr).Decode(&msgMap)
if err != nil {
ws.writeError(-1, Error{Code: "invalid_format", Message: err.Error()})
}
ch <- msgMap
}
}(cCtx, msgChan)
for {
select {
case msg, ok := <-msgChan:
if !ok {
return nil
}
err = ws.handleMsg(ctx, msg)
if err != nil {
log.Error().Err(err).Msg("handleMsg")
}
case <-ctx.Done():
close(msgChan) // maybe remove this?
return nil
case m := <-write:
err := ws.conn.WriteJSON(m)
if err != nil {
log.Error().Err(err).Msg("writeMsg")
}
}
}
}
func (ws *wsSession) Write(msg interface{}) {
ws.write <- msg
}
type MsgType string
const (
ResultMsgType MsgType = "result"
)
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
}
type WSError struct {
ID *int `json:"id,omitempty"`
Type MsgType `json:"type"`
Success bool `json:"success"`
Error Error `json:"error"`
}
func (ws *wsSession) writeError(id int, err Error) error {
return ws.conn.WriteJSON(WSError{
ID: &id,
Type: ResultMsgType,
Success: false,
Error: err,
})
}
func (ws *wsSession) handleMsg(ctx context.Context, msgMap map[string]interface{}) error {
if err := ctx.Err(); err != nil {
return nil
}
msgType, ok := msgMap["type"].(string)
if !ok {
return NoSuchHandlerErr
}
idFl, ok := msgMap["id"].(float64)
if !ok {
ws.Write(WSError{
Type: ResultMsgType,
Success: false,
Error: Error{
Code: "invalid_id",
Message: "command has no ID",
},
})
return nil
}
id := int(idFl)
cmd := strings.Split(msgType, "/")
newData, hand, err := ws.b.WSCommandHandler(cmd[0], cmd)
switch err {
case nil:
case NoSuchHandlerErr:
ws.writeError(id, Error{
Code: "invalid_type",
Message: "no such command",
})
return nil
default:
log.Error().Err(err).Msg("dispatch")
return nil
}
nd := newData(cmd)
if _, ok := nd.(map[string]interface{}); !ok {
err := mapstructure.Decode(&msgMap, &nd)
if err != nil {
ws.writeError(id, Error{
Code: "invalid_format",
Message: err.Error(),
})
return nil
}
}
err = hand(ctx, ws, id, cmd, nd)
if err != nil {
log.Error().Err(err).Msg("dispatch")
}
return nil
}

View file

@ -1,81 +0,0 @@
package wsapi
import (
"context"
"encoding/json"
"io"
"dynatron.me/x/blasphem/pkg/auth"
"github.com/rs/zerolog/log"
)
type authPhase struct {
*wsSession
}
func (ws *wsSession) sendAuthRequired() error {
authReq := &struct {
MsgBase
Version string `json:"ha_version"`
}{
MsgBase{Type: "auth_required"},
ws.b.Version(),
}
return ws.conn.WriteJSON(&authReq)
}
type authMsg struct {
MsgBase
AccessToken auth.AccessToken `json:"access_token"`
}
func (ap *authPhase) msgSchema() interface{} {
return &authMsg{}
}
func (ap *authPhase) finishAuth(rt *auth.RefreshToken) {
ap.user = rt.User
ap.refreshToken = rt
}
func (ap *authPhase) sendAuthOK() error {
return ap.conn.WriteJSON(struct {
Type string `json:"type"`
Version string `json:"ha_version"`
}{Type: "auth_ok", Version: ap.Blas().Version()})
}
func (ap *authPhase) sendAuthInvalid() error {
return ap.conn.WriteJSON(struct {
Type string `json:"type"`
Message string `json:"message"`
}{Type: "auth_ok", Message: "invalid auth"})
}
func (ap *authPhase) handleMsg(ctx context.Context, r io.Reader) error {
var authMsg authMsg
err := json.NewDecoder(r).Decode(&authMsg)
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
refreshToken := ap.b.ValidateAccessToken(authMsg.AccessToken)
if refreshToken != nil {
ap.finishAuth(refreshToken)
return ap.sendAuthOK()
}
log.Error().Str("remote", ap.ec.Request().RemoteAddr).Msg("websocket auth failed")
err = ap.sendAuthInvalid()
if err != nil {
return err
}
return AuthInvalidErr
}