diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ebec1f5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 +max_line_length = 120 + +[*.go] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +indent_size = 4 +max_line_length = 120 diff --git a/cmd/admin/user.go b/cmd/admin/user.go index 1765540..31ae785 100644 --- a/cmd/admin/user.go +++ b/cmd/admin/user.go @@ -71,6 +71,10 @@ var userAddCommand = &command.Command{ Description: "a custom greeting for the user", Default: "", }, + "admin": { + Type: "bool", + Description: "make this user an admin", + }, }, Action: func(cmd *command.Command) error { config := cmd.Options["config"].ToValue().(string) @@ -80,6 +84,7 @@ var userAddCommand = &command.Command{ schedule := cmd.Options["schedule"].ToString() ttl := cmd.Options["ttl"].ToString() greeting := cmd.Options["greeting"].ToString() + admin := cmd.Options["admin"].ToValue().(bool) data, err := os.ReadFile(config) if err != nil { @@ -105,19 +110,16 @@ var userAddCommand = &command.Command{ return err } - var ttlDuration auth.Duration - if err := ttlDuration.UnmarshalDB(ttl); err != nil { - return err - } - user := &auth.User{ Name: cmd.Arguments[0].ToString(), Password: string(password), Handle: cmd.Arguments[1].ToString(), Greeting: greeting, - TTL: ttlDuration, + IsAdmin: admin, } + *user.TTL = auth.TTL(ttl) + if schedule != "" { user.Schedule = &auth.UserSchedule{} if err := user.Schedule.UnmarshalDB([]byte(schedule)); err != nil { diff --git a/cmd/hue/main.go b/cmd/hue/main.go index 31cac29..ec4f185 100644 --- a/cmd/hue/main.go +++ b/cmd/hue/main.go @@ -3,6 +3,7 @@ package hue import ( + "fmt" "os" "git.rob.mx/nidito/chinampa" @@ -37,13 +38,18 @@ var setupHueCommand = &command.Command{ domain := cmd.Arguments[1].ToValue().(string) logrus.Infof("Setting up with bridge at %s, app %s", ip, domain) - d := door.NewHue(map[string]any{ + doorI, err := door.NewHue(map[string]any{ "ip": ip, "username": "", "device": -1, - }).(*door.Hue) + }) + if err != nil { + return fmt.Errorf("could not connect to door: %s", err) + } - return d.Setup(os.Args[2]) + adapter := doorI.(*door.Hue) + + return adapter.Setup(os.Args[2]) }, } @@ -74,12 +80,16 @@ var testHueCommand = &command.Command{ device := cmd.Arguments[2].ToValue().(string) logrus.Infof("Testing bridge at %s, username %s, device %s", ip, username, device) - d := door.NewHue(map[string]any{ + + err := door.Connect(map[string]any{ + "adapter": "hue", "ip": ip, "username": username, "device": device, }) - - return door.RequestToEnter(d, "test") + if err != nil { + return fmt.Errorf("could not connect to door: %s", err) + } + return door.RequestToEnter("test") }, } diff --git a/go.mod b/go.mod index 3678fd5..8456c03 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.rob.mx/nidito/puerta go 1.18 require ( - git.rob.mx/nidito/chinampa v0.0.0-20230101000754-0d24a8007547 + git.rob.mx/nidito/chinampa v0.0.0-20230102065449-d9b257e145ce github.com/alexedwards/scs/v2 v2.5.0 github.com/amimof/huego v1.2.1 github.com/go-webauthn/webauthn v0.6.0 diff --git a/go.sum b/go.sum index d685c55..f1a8867 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -git.rob.mx/nidito/chinampa v0.0.0-20221229190558-4eec8e55a1e6 h1:yTb1uOLMMGKGcA1fr1XHJyRcKb6OXhgvcLDLzr/lua0= -git.rob.mx/nidito/chinampa v0.0.0-20221229190558-4eec8e55a1e6/go.mod h1:nQlQqIQ6UuP6spFFZvfVT1MhQJYEA7B3Y2EtM2Fha3Y= -git.rob.mx/nidito/chinampa v0.0.0-20221230070026-831e68c7b70a h1:kvoP8pEeIr4aSK00IiKoANMxM3r4aXnvJOmLRBbglG0= -git.rob.mx/nidito/chinampa v0.0.0-20221230070026-831e68c7b70a/go.mod h1:nQlQqIQ6UuP6spFFZvfVT1MhQJYEA7B3Y2EtM2Fha3Y= -git.rob.mx/nidito/chinampa v0.0.0-20221231055324-8ea5f42ef848 h1:Nvyo7qK6oVLWQ2aHRtQ5AAMcVEue51Wr+hxBF4OzMkE= -git.rob.mx/nidito/chinampa v0.0.0-20221231055324-8ea5f42ef848/go.mod h1:jZwWmhBRfjJjp2jwM/+jIGgfWLQPudgAah+wKCKjBfk= -git.rob.mx/nidito/chinampa v0.0.0-20230101000754-0d24a8007547 h1:9a4zHaJbeTqcPnF0WKNa+Kgzm+ZD3f887ZZTRWDPh3M= -git.rob.mx/nidito/chinampa v0.0.0-20230101000754-0d24a8007547/go.mod h1:obhWsLkUIlKJyhfa7uunrSs2O44JBqsegSAtAvY2LRM= +git.rob.mx/nidito/chinampa v0.0.0-20230102065449-d9b257e145ce h1:fKG3wUdPgsviY2mE79vhXL4CalNdvhkL6vDAtdyVt0I= +git.rob.mx/nidito/chinampa v0.0.0-20230102065449-d9b257e145ce/go.mod h1:obhWsLkUIlKJyhfa7uunrSs2O44JBqsegSAtAvY2LRM= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= @@ -19,7 +13,6 @@ github.com/alexedwards/scs/v2 v2.5.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gv github.com/amimof/huego v1.2.1 h1:kd36vsieclW4fZ4Vqii9DNU2+6ptWWtkp4OG0AXM8HE= github.com/amimof/huego v1.2.1/go.mod h1:z1Sy7Rrdzmb+XsGHVEhODrRJRDq4RCFW7trCI5cKmeA= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E= github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= @@ -51,7 +44,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/denisenkom/go-mssqldb v0.11.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= @@ -126,7 +118,6 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgf github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -254,7 +245,6 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -315,7 +305,6 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU= github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M= github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -371,8 +360,6 @@ golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.3.0 h1:VWL6FNY2bEEmsGVKabSlHu5Irp34xmMRoqb/9lF9lxk= -golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU= golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 11d5821..cb5fa95 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -10,7 +10,6 @@ import ( "net/http" "time" - "git.rob.mx/nidito/puerta/internal/door" "github.com/alexedwards/scs/v2" "github.com/go-webauthn/webauthn/webauthn" "github.com/julienschmidt/httprouter" @@ -21,25 +20,20 @@ import ( type AuthContext string const ( - ContextCookieName AuthContext = "_puerta" - ContextSessionName AuthContext = "_rex" - ContextUserName AuthContext = "auth-username" - ContextGreeting AuthContext = "auth-greeting" - ContextDoor AuthContext = "auth-door" + ContextCookieName AuthContext = "_puerta" + ContextUser AuthContext = "_user" ) type Manager struct { db db.Session - door door.Door wan *webauthn.WebAuthn sess *scs.SessionManager } -func NewManager(wan *webauthn.WebAuthn, door door.Door, db db.Session) *Manager { +func NewManager(wan *webauthn.WebAuthn, db db.Session) *Manager { sessionManager := scs.New() sessionManager.Lifetime = 5 * time.Minute return &Manager{ - door: door, db: db, wan: wan, sess: sessionManager, @@ -101,89 +95,126 @@ func (am *Manager) NewSession(w http.ResponseWriter, req *http.Request, ps httpr } } -func (am *Manager) Protected(handler httprouter.Handle, redirect, enforce2FA bool) httprouter.Handle { - +func (am *Manager) withUser(handler httprouter.Handle) httprouter.Handle { return func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { - ctx := req.Context() - var user *User - if ctxUser := ctx.Value(ContextUserName); ctxUser == nil { - cookie, err := req.Cookie(string(ContextCookieName)) - - if err != nil { - logrus.Debugf("no cookie value found in <%s>", req.Cookies()) - if redirect { - http.Redirect(w, req, "/login", http.StatusTemporaryRedirect) - } else { - am.requestAuth(w, http.StatusUnauthorized) - } - return + ctxUser := req.Context().Value(ContextUser) + req = func() *http.Request { + if ctxUser != nil { + return req } + cookie, err := req.Cookie(string(ContextCookieName)) + if err != nil { + logrus.Debugf("no cookie for user found in jar <%s>", req.Cookies()) + return req + } + + session := &SessionUser{} q := am.db.SQL(). Select("s.token as token, ", "u.*"). From("session as s"). Join("user as u").On("s.user = u.id"). Where(db.Cond{"s.token": cookie.Value}) - session := &SessionUser{} if err := q.One(&session); err != nil { + logrus.Debugf("no cookie found in DB for jar <%s>: %s", req.Cookies(), err) w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Secure; Path=/;", ContextCookieName, "", -1)) - if redirect { - http.Redirect(w, req, "/login", http.StatusSeeOther) - } else { - am.requestAuth(w, http.StatusUnauthorized) + return req + } + + if session.Expired() { + logrus.Debugf("expired cookie found in DB for jar <%s>", req.Cookies()) + w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Secure; Path=/;", ContextCookieName, "", -1)) + err := am.db.Collection("session").Find(db.Cond{"token": cookie.Value}).Delete() + if err != nil { + logrus.Errorf("could not purge expired session from DB: %s", err) } - return + return req } - if err := session.User.IsAllowed(time.Now()); err != nil { - logrus.Errorf("Denying access to %s: %s", session.User.Name, err) - am.requestAuth(w, http.StatusForbidden) - return - } + return req.WithContext(context.WithValue(req.Context(), ContextUser, &session.User)) + }() - req = req.WithContext(context.WithValue(ctx, ContextUserName, session.User.Name)) - req = req.WithContext(context.WithValue(req.Context(), ContextGreeting, session.User.Greeting)) - req = req.WithContext(context.WithValue(req.Context(), ContextDoor, am.door)) - logrus.Debug("found allowed user") - user = &session.User - } - - if enforce2FA && user.Require2FA { - logrus.Debug("Enforcing 2fa for request") - var err error - err = user.FetchCredentials(am.db) - if err != nil { - logrus.Errorf("Failed fetching credentials: %s", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - } - - if len(user.credentials) == 0 { - err = am.WebAuthnRegister(user, req) - } else { - err = am.WebAuthnLogin(user, req) - } - - if err != nil { - if wafc, ok := err.(WebAuthFlowChallenge); ok { - logrus.Debugf("Issuing challenge") - w.WriteHeader(200) - w.Header().Add("content-type", "application/json") - w.Write([]byte(wafc.Error())) - logrus.Debugf("Issued challenge") - return - } - - logrus.Errorf("Failed during webauthn flow: %s", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - } handler(w, req, ps) } } -func (am *Manager) WebAuthnRegister(user *User, req *http.Request) error { +func (am *Manager) RequireAuthOrRedirect(handler httprouter.Handle, target string) httprouter.Handle { + return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + if req.Context().Value(ContextUser) == nil { + http.Redirect(w, req, target, http.StatusTemporaryRedirect) + return + } + + handler(w, req, ps) + }) +} + +func (am *Manager) Enforce2FA(handler httprouter.Handle) httprouter.Handle { + return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + if req.Context().Value(ContextUser) == nil { + am.requestAuth(w, http.StatusUnauthorized) + return + } + + user := req.Context().Value(ContextUser).(*User) + if !user.Require2FA { + handler(w, req, ps) + return + } + + logrus.Debug("Enforcing 2fa for request") + var err error + err = user.FetchCredentials(am.db) + if err != nil { + logrus.Errorf("Failed fetching credentials: %s", err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if len(user.credentials) == 0 { + err = am.WebAuthnRegister(req) + } else { + err = am.WebAuthnLogin(req) + } + + if err != nil { + if wafc, ok := err.(WebAuthFlowChallenge); ok { + w.WriteHeader(200) + w.Header().Add("content-type", "application/json") + w.Write([]byte(wafc.Error())) + return + } + + logrus.Errorf("Failed during webauthn flow: %s", err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + handler(w, req, ps) + }) +} + +func (am *Manager) RequireAdmin(handler httprouter.Handle) httprouter.Handle { + return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + if req.Context().Value(ContextUser) == nil { + am.requestAuth(w, http.StatusUnauthorized) + return + } + + user := req.Context().Value(ContextUser).(*User) + + if !user.IsAdmin { + am.requestAuth(w, http.StatusUnauthorized) + + return + } + handler(w, req, ps) + }) +} + +func (am *Manager) WebAuthnRegister(req *http.Request) error { + user := UserFromContext(req) sd := am.sess.GetBytes(req.Context(), "wan-register") if sd == nil { logrus.Infof("Starting webauthn registration for %s", user.Name) @@ -228,7 +259,8 @@ func (am *Manager) WebAuthnRegister(user *User, req *http.Request) error { return err } -func (am *Manager) WebAuthnLogin(user *User, req *http.Request) error { +func (am *Manager) WebAuthnLogin(req *http.Request) error { + user := UserFromContext(req) sd := am.sess.GetBytes(req.Context(), "rex") if sd == nil { logrus.Infof("Starting webauthn login flow for %s", user.Name) diff --git a/internal/auth/duration.go b/internal/auth/duration.go index eef81cc..9325fdf 100644 --- a/internal/auth/duration.go +++ b/internal/auth/duration.go @@ -8,16 +8,14 @@ import ( "time" ) -type Duration time.Duration +var DefaultTTL = TTL("30d") -func (d Duration) MarshalDB() (any, error) { - return time.Duration(d).String(), nil -} +type TTL string -func (d *Duration) UnmarshalDB(value any) error { - str := value.(string) - suffix := str[len(str)-1] +func (ttl TTL) ToDuration() (res time.Duration, err error) { + suffix := ttl[len(ttl)-1] + toParse := string(ttl) if suffix == 'd' || suffix == 'w' || suffix == 'M' { multiplier := 1 switch suffix { @@ -28,29 +26,32 @@ func (d *Duration) UnmarshalDB(value any) error { case 'M': multiplier = 24 * 7 * 30 default: - return fmt.Errorf("unknown suffix for time duration %s", string(suffix)) + err = fmt.Errorf("unknown suffix for time duration %s", string(suffix)) + return } - str = str[0 : len(str)-1] - days, err := strconv.Atoi(str) + toParse = toParse[0 : len(toParse)-1] + var days int + days, err = strconv.Atoi(toParse) if err != nil { - return err + return } - str = fmt.Sprintf("%dh", days*multiplier) + toParse = fmt.Sprintf("%dh", days*multiplier) } - tmp, err := time.ParseDuration(str) - if err != nil { - return err - } - *d = Duration(tmp) - return nil + res, err = time.ParseDuration(toParse) + return } -func (d *Duration) FromNow() time.Time { - return time.Now().Add(time.Duration(*d)) +func (ttl *TTL) FromNow() time.Time { + d, _ := ttl.ToDuration() + return time.Now().Add(d) } -func (d *Duration) Seconds() int { - return int(time.Duration(*d).Seconds()) +func (ttl *TTL) Seconds() int { + d, _ := ttl.ToDuration() + return int(d.Seconds()) } + +// var _ = (db.Unmarshaler(&TTL{})) +// var _ = (db.Marshaler(&TTL{})) diff --git a/internal/auth/schedule.go b/internal/auth/schedule.go index 794209c..d9553f0 100644 --- a/internal/auth/schedule.go +++ b/internal/auth/schedule.go @@ -10,6 +10,7 @@ import ( "time" "github.com/sirupsen/logrus" + "github.com/upper/db/v4" ) func parseHour(src string) (float64, error) { @@ -40,13 +41,17 @@ type UserSchedule struct { hours []float64 } -func (d UserSchedule) MarshalDB() ([]byte, error) { +func (d UserSchedule) MarshalDB() (any, error) { return json.Marshal(d.src) } -func (d *UserSchedule) UnmarshalDB(b []byte) error { +func (d UserSchedule) MarshalJSON() ([]byte, error) { + return json.Marshal(d.src) +} + +func (d *UserSchedule) UnmarshalDB(b any) error { var v string - if err := json.Unmarshal(b, &v); err != nil { + if err := json.Unmarshal(b.([]byte), &v); err != nil { return err } @@ -104,3 +109,6 @@ func (sch *UserSchedule) AllowedAt(t time.Time) bool { return true } + +var _ = (db.Unmarshaler(&UserSchedule{})) +var _ = (db.Marshaler(&UserSchedule{})) diff --git a/internal/auth/user.go b/internal/auth/user.go index 1ac27a6..a07b64c 100644 --- a/internal/auth/user.go +++ b/internal/auth/user.go @@ -30,16 +30,26 @@ func (c *Credential) AsWebAuthn() webauthn.Credential { return *c.wan } +func UserFromContext(req *http.Request) *User { + u := req.Context().Value(ContextUser) + + if u != nil { + return u.(*User) + } + return nil +} + type User struct { - ID int `db:"id"` - Handle string `db:"user"` - Name string `db:"name"` - Password string `db:"password"` - Schedule *UserSchedule `db:"schedule,omitempty"` - Expires *time.Time `db:"expires,omitempty"` - Greeting string `db:"greeting"` - TTL Duration `db:"max_ttl"` - Require2FA bool `db:"second_factor"` + ID int `db:"id" json:"-"` + Handle string `db:"user" json:"user"` + Name string `db:"name" json:"name"` + Password string `db:"password" json:"password"` + Schedule *UserSchedule `db:"schedule,omitempty" json:"schedule"` + Expires *time.Time `db:"expires,omitempty" json:"expires"` + Greeting string `db:"greeting" json:"greeting"` + TTL *TTL `db:"max_ttl,omitempty" json:"max_ttl"` + Require2FA bool `db:"second_factor" json:"second_factor"` + IsAdmin bool `db:"is_admin" json:"admin"` credentials []*Credential } @@ -92,7 +102,7 @@ func (u *User) FetchCredentials(sess db.Session) error { func (o *User) UnmarshalJSON(b []byte) error { type alias User - xo := &alias{TTL: Duration(30 * 24 * time.Hour)} + xo := &alias{TTL: &DefaultTTL} if err := json.Unmarshal(b, xo); err != nil { return err } @@ -129,3 +139,5 @@ func (user *User) Login(password string) error { return nil } + +var _ = db.Record(&User{}) diff --git a/internal/door/door.go b/internal/door/door.go index f5ad892..570600b 100644 --- a/internal/door/door.go +++ b/internal/door/door.go @@ -13,9 +13,10 @@ import ( var ( isOpening bool statusMu sync.Mutex + Active Door ) -type newDoorFunc func(map[string]any) Door +type newDoorFunc func(map[string]any) (Door, error) var adapters = &struct { factories map[string]newDoorFunc @@ -42,20 +43,20 @@ func setStatus(status bool) { } // RequestToEnter opens the door unless it's already open or opening -func RequestToEnter(door Door, username string) error { +func RequestToEnter(username string) error { statusMu.Lock() if isOpening { defer statusMu.Unlock() - return &DoorCommunicationError{"checking status", fmt.Errorf("Door is busy processing another request")} + return &ErrorCommunication{"checking status", fmt.Errorf("Door is busy processing another request")} } - isOpen, err := door.IsOpen() + isOpen, err := Active.IsOpen() if err != nil { statusMu.Unlock() - return &DoorCommunicationError{"checking status", err} + return &ErrorCommunication{"checking status", err} } else if isOpen { statusMu.Unlock() - return &DoorAlreadyOpen{} + return &ErrorAlreadyOpen{} } // okay, we're triggering an open and preventing others @@ -66,11 +67,11 @@ func RequestToEnter(door Door, username string) error { errors := make(chan error, 2) done := make(chan bool) - go door.Open(errors, done) + go Active.Open(errors, done) if err = <-errors; err != nil { setStatus(false) - return &DoorCommunicationError{"opening", err} + return &ErrorCommunication{"opening", err} } logrus.Infof("Door opened for %s", username) @@ -94,16 +95,17 @@ func RequestToEnter(door Door, username string) error { return nil } -func NewDoor(config map[string]any) (Door, error) { +func Connect(config map[string]any) (err error) { adapterName, hasAdapter := config["kind"] if !hasAdapter { - return nil, fmt.Errorf("missing DOOR_ADAPTER") + return fmt.Errorf("missing DOOR_ADAPTER") } factory, exists := adapters.factories[adapterName.(string)] if !exists { - return nil, fmt.Errorf("unknown DOOR_ADAPTER \"%s\", not one of [%s]", adapterName, strings.Join(adapters.names, ",")) + return fmt.Errorf("unknown DOOR_ADAPTER \"%s\", not one of [%s]", adapterName, strings.Join(adapters.names, ",")) } - return factory(config), nil + Active, err = factory(config) + return err } diff --git a/internal/door/errors.go b/internal/door/errors.go index 0a5f339..799fcb2 100644 --- a/internal/door/errors.go +++ b/internal/door/errors.go @@ -7,25 +7,39 @@ import ( "net/http" ) -type DoorCommunicationError struct { +type ErrorCommunication struct { during string err error } -func (err *DoorCommunicationError) Error() string { - return fmt.Sprintf("ould not get door status while %s: %s", err.during, err.err.Error()) +func (err *ErrorCommunication) Error() string { + return fmt.Sprintf("could not get door status while %s: %s", err.during, err.err.Error()) } -func (err *DoorCommunicationError) Code() int { +func (err *ErrorCommunication) Code() int { return http.StatusInternalServerError } -type DoorAlreadyOpen struct{} +func (err *ErrorCommunication) Name() string { + return "communication-error" +} -func (err *DoorAlreadyOpen) Error() string { +type ErrorAlreadyOpen struct{} + +func (err *ErrorAlreadyOpen) Error() string { return "door is already open" } -func (err *DoorAlreadyOpen) Code() int { +func (err *ErrorAlreadyOpen) Code() int { return http.StatusPreconditionFailed } + +func (err *ErrorAlreadyOpen) Name() string { + return "already-open" +} + +type Error interface { + Error() string + Code() int + Name() string +} diff --git a/internal/door/hue.go b/internal/door/hue.go index 0b07f17..da2dbbb 100644 --- a/internal/door/hue.go +++ b/internal/door/hue.go @@ -28,7 +28,7 @@ func init() { _register("hue", NewHue) } -func NewHue(config map[string]any) Door { +func NewHue(config map[string]any) (Door, error) { cfg := &HueConfig{ ip: config["ip"].(string), @@ -49,11 +49,11 @@ func NewHue(config map[string]any) Door { if cfg.username != "" && cfg.device > -1 { device, err := h.bridge.GetLight(cfg.device) if err != nil { - panic(err) + return nil, err } h.device = device } - return h + return h, nil } func (h *Hue) Setup(domain string) error { diff --git a/internal/door/mock.go b/internal/door/mock.go index b650cd4..c150cd7 100644 --- a/internal/door/mock.go +++ b/internal/door/mock.go @@ -18,11 +18,11 @@ type mockDoor struct { FailedToClose error } -func NewMock(config map[string]any) Door { +func NewMock(config map[string]any) (Door, error) { logrus.Info("Initializing mock client") return &mockDoor{ Status: false, - } + }, nil } func (md *mockDoor) IsOpen() (bool, error) { diff --git a/internal/door/wemo.go b/internal/door/wemo.go index d58976f..ce8b91f 100644 --- a/internal/door/wemo.go +++ b/internal/door/wemo.go @@ -24,12 +24,12 @@ type Wemo struct { client *http.Client } -func NewWemo(config map[string]any) Door { +func NewWemo(config map[string]any) (Door, error) { logrus.Infof("Wemo client for %s starting", config["endpoint"]) return &Wemo{ endpoint: config["endpoint"].(string), client: &http.Client{Timeout: 4 * time.Second}, - } + }, nil } const wemoBodyGet string = ` diff --git a/internal/server/admin.go b/internal/server/admin.go new file mode 100644 index 0000000..7e59f49 --- /dev/null +++ b/internal/server/admin.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright © 2022 Roberto Hidalgo +package server + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "git.rob.mx/nidito/puerta/internal/auth" + "github.com/julienschmidt/httprouter" + "github.com/sirupsen/logrus" + "github.com/upper/db/v4" + "golang.org/x/crypto/bcrypt" +) + +func sendError(w http.ResponseWriter, err error) { + logrus.Error(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) +} + +func writeJSON(w http.ResponseWriter, data any) error { + res, err := json.Marshal(data) + if err != nil { + return err + } + + w.Header().Add("content-type", "application/json") + w.WriteHeader(http.StatusOK) + _, err = w.Write(res) + return err +} + +func listUsers(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + users := []*auth.User{} + if err := _db.Collection("user").Find().All(&users); err != nil { + sendError(w, err) + return + } + + writeJSON(w, users) +} + +func userFromRequest(r *http.Request, user *auth.User) (*auth.User, error) { + r.ParseForm() + if user == nil { + user = &auth.User{} + } + + isAdmin, err := strconv.ParseBool(r.FormValue("is_admin")) + if err != nil { + return nil, err + } + secondFactor, err := strconv.ParseBool(r.FormValue("second_factor")) + if err != nil { + return nil, err + } + + user.Handle = r.FormValue("handle") + user.Name = r.FormValue("name") + user.Greeting = r.FormValue("greeting") + user.Require2FA = secondFactor + user.IsAdmin = isAdmin + + if r.FormValue("password") != "" { + password, err := bcrypt.GenerateFromPassword([]byte(r.FormValue("password")), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + user.Password = string(password) + } + + if r.Form.Has("schedule") { + schedule := &auth.UserSchedule{} + err := schedule.UnmarshalDB([]byte(r.FormValue("schedule"))) + if err != nil { + return nil, err + } + user.Schedule = schedule + } + + if r.Form.Has("expires") { + expires, err := time.Parse(time.RFC3339, r.FormValue("expires")) + if err != nil { + return nil, err + } + user.Expires = &expires + } + + if r.Form.Has("max_ttl") { + *user.TTL = auth.TTL(r.FormValue("max_ttl")) + } + + return user, nil + +} + +func createUser(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + user, err := userFromRequest(r, nil) + if err != nil { + sendError(w, err) + return + } + + if _, err := _db.Collection("user").Insert(user); err != nil { + sendError(w, err) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func getUser(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + var user *auth.User + idString := params.ByName("id") + + if err := _db.Collection("user").Find(db.Cond{"handle": idString}).One(&user); err != nil { + sendError(w, err) + return + } + + writeJSON(w, user) +} + +func updateUser(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + var user *auth.User + if err := _db.Collection("user").Find(db.Cond{"handle": params.ByName("id")}).One(user); err != nil { + http.NotFound(w, r) + return + } + + user, err := userFromRequest(r, user) + if err != nil { + sendError(w, err) + return + } + + if err := _db.Collection("user").UpdateReturning(user); err != nil { + sendError(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func deleteUser(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + err := _db.Collection("user").Find(db.Cond{"handle": params.ByName("id")}).Delete() + if err != nil { + sendError(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/admin.html b/internal/server/admin.html new file mode 100644 index 0000000..f7de703 --- /dev/null +++ b/internal/server/admin.html @@ -0,0 +1,102 @@ + + + + + + + + puerta@nidi.to + + + + + +
+
+

Puerta

+

Admin

+
+
+
+
+

Crear usuario

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+

Usuarios

+
    +
    + + +
    + + + + + diff --git a/internal/server/server.go b/internal/server/server.go index ca4893c..1cb6752 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,12 +8,14 @@ import ( "io/fs" "log" "net/http" + "time" "git.rob.mx/nidito/puerta/internal/auth" "git.rob.mx/nidito/puerta/internal/door" "git.rob.mx/nidito/puerta/internal/errors" "github.com/go-webauthn/webauthn/webauthn" "github.com/julienschmidt/httprouter" + "github.com/sirupsen/logrus" "github.com/upper/db/v4" "github.com/upper/db/v4/adapter/sqlite" ) @@ -24,6 +26,9 @@ var loginTemplate []byte //go:embed index.html var indexTemplate []byte +//go:embed admin.html +var adminTemplate []byte + //go:embed static/* var staticFiles embed.FS @@ -50,6 +55,41 @@ func ConfigDefaults(dbPath string) *Config { } } +type auditLog struct { + Timestamp time.Time `db:"timestamp"` + User string `db:"user"` + SecondFactor bool `db:"second_factor"` + Failure string `db:"failure"` + Err string `db:"error"` + Success bool `db:"success"` + IpAddress string `db:"ip_address"` + UserAgent string `db:"user_agent"` +} + +func newAuditLog(r *http.Request, err error) *auditLog { + user := auth.UserFromContext(r) + ip := r.RemoteAddr + ua := r.Header.Get("user-agent") + + al := &auditLog{ + Timestamp: time.Now(), + User: user.Handle, + SecondFactor: user.Require2FA, + IpAddress: ip, + UserAgent: ua, + } + + if err != nil { + al.Failure = err.Error() + if derr, ok := err.(door.Error); ok { + al.Err = derr.Name() + al.Failure = derr.Error() + } + } + + return al +} + func CORS(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Access-Control-Request-Method") != "" { // Set CORS headers @@ -63,9 +103,26 @@ func CORS(w http.ResponseWriter, r *http.Request) { } func rex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - userName := r.Context().Value(auth.ContextUserName).(string) + var err error + user := r.Context().Value(auth.ContextUser).(*auth.User) - if err := door.RequestToEnter(_door, userName); err != nil { + defer func() { + _, sqlErr := _db.Collection("log").Insert(newAuditLog(r, err)) + if sqlErr != nil { + logrus.Errorf("could not record error log: %s", sqlErr) + } + }() + + err = user.IsAllowed(time.Now()) + if err != nil { + logrus.Errorf("Denying rex to %s: %s", user.Name, err) + http.Error(w, "Access denied", http.StatusForbidden) + return + } + + err = door.RequestToEnter(user.Name) + + if err != nil { message, code := errors.ToHTTP(err) http.Error(w, message, code) return @@ -75,7 +132,6 @@ func rex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { } var _db db.Session -var _door door.Door func Initialize(config *Config) (http.Handler, error) { router := httprouter.New() @@ -94,8 +150,7 @@ func Initialize(config *Config) (http.Handler, error) { return nil, err } - _door, err = door.NewDoor(config.Adapter) - if err != nil { + if err := door.Connect(config.Adapter); err != nil { return nil, err } @@ -111,7 +166,7 @@ func Initialize(config *Config) (http.Handler, error) { return nil, err } - am := auth.NewManager(wan, _door, _db) + am := auth.NewManager(wan, _db) serverRoot, err := fs.Sub(staticFiles, "static") if err != nil { @@ -120,9 +175,16 @@ func Initialize(config *Config) (http.Handler, error) { router.ServeFiles("/static/*filepath", http.FS(serverRoot)) router.GET("/login", renderTemplate(loginTemplate)) - router.GET("/", am.Protected(renderTemplate(indexTemplate), true, false)) + router.GET("/", am.RequireAuthOrRedirect(renderTemplate(indexTemplate), "/login")) router.POST("/api/login", am.NewSession) - router.POST("/api/rex", am.Protected(rex, false, true)) + router.POST("/api/rex", am.Enforce2FA(rex)) + router.GET("/admin", am.RequireAdmin(renderTemplate(adminTemplate))) + router.GET("/api/user", am.RequireAdmin(listUsers)) + // router.GET("/api/user/:id", am.RequireAdmin(getUser)) + router.GET("/api/user/:id", getUser) + router.PUT("/api/user", am.RequireAdmin(am.Enforce2FA(createUser))) + router.POST("/api/user/:id", am.RequireAdmin(am.Enforce2FA(updateUser))) + router.DELETE("/api/user/:id", am.RequireAdmin(am.Enforce2FA(deleteUser))) return am.Route(router), nil } diff --git a/internal/server/static/admin.js b/internal/server/static/admin.js new file mode 100644 index 0000000..ec143f0 --- /dev/null +++ b/internal/server/static/admin.js @@ -0,0 +1,70 @@ +const button = document.querySelector("#open button") +const form = document.querySelector("#open") +const { create: createCredentials, get: getCredentials } = hankoWebAuthn; + +const userList = document.querySelector("#user-list > ul") + + +customElements.define( + "user-info-panel", + class extends HTMLElement { + constructor() { + super() + let template = document.getElementById("user-info-panel") + const shadowRoot = this.attachShadow({ mode: "open" }) + const panel = template.content.cloneNode(true) + + panel.querySelector('h3').textContent = this.getAttribute('name') + panel.querySelector('name').value = this.getAttribute('name') + panel.querySelector('pre').textContent = this.getAttribute('handle') + + panel.querySelector('greeting').value = this.getAttribute('greeting') + panel.querySelector('schedule').value = this.getAttribute('schedule') + panel.querySelector('expires').value = this.getAttribute('expires') + panel.querySelector('max_ttl').value = this.getAttribute('ttl') + panel.querySelector('is_admin').checked = this.hasAttribute('admin') + panel.querySelector('second_factor').checked = this.hasAttribute('second_factor') + shadowRoot.appendChild(panel) + } + } +); + +async function fetchUsers() { + console.debug("fetching users") + let response = await window.fetch("/api/user") + + if (!response.ok) { + alert("Could not load users") + return + } + + let json = {} + try { + json = await response.json() + } catch (err) { + alert(err) + return + } + + userList.replaceChildren(json.map(u => { + const ip = document.createElement("user-info-panel") + ip.setAttribute("name", u.name) + ip.setAttribute("handle", u.handle) + + ip.setAttribute('greeting', u.greeting) + ip.setAttribute('schedule', u.schedule) + ip.setAttribute('expires', u.expires) + ip.setAttribute('max_ttl', u.ttl) + if (u.admin) { + ip.setAttribute('is_admin', "") + } + if (u.second_factor) { + ip.setAttribute('second_factor', "") + } + + return ip + })) +} + + + diff --git a/internal/server/static/serviceworker.js b/internal/server/static/serviceworker.js index 12f28b3..bb9201c 100644 --- a/internal/server/static/serviceworker.js +++ b/internal/server/static/serviceworker.js @@ -4,10 +4,11 @@ importScripts( workbox.loadModule('workbox-strategies'); + self.addEventListener("install", event => { console.log("Service worker installed"); - const urlsToCache = ["/", "app.js", "styles.css", "logo.svg"]; + const urlsToCache = ["/login", "/", "index.css", "/index.js", "/login.js"]; event.waitUntil( caches.open("pwa-assets") .then(cache => { @@ -22,7 +23,7 @@ self.addEventListener("activate", event => { self.addEventListener('fetch', event => { - if (event.request.url.endsWith('.png')) { + if (event.request.url.endsWith('.js') || event.request.url.endsWith('.css')) { const cacheFirst = new workbox.strategies.CacheFirst(); event.respondWith(cacheFirst.handle({request: event.request})); } diff --git a/schema.sql b/schema.sql index 2210996..5ceee3b 100644 --- a/schema.sql +++ b/schema.sql @@ -1,22 +1,24 @@ CREATE TABLE user( id INTEGER PRIMARY KEY AUTOINCREMENT, - name VARCHAR(255) NOT NULL UNIQUE, + handle VARCHAR(255) NOT NULL UNIQUE, + name TEXT NOT NULL, password TEXT, expires TEXT, -- datetime greeting TEXT, - max_ttl TEXT DEFAULT "30d", -- golang auth.Duration + max_ttl TEXT DEFAULT "30d", -- golang auth.TTL + schedule TEXT, -- golang auth.UserSchedule second_factor BOOLEAN DEFAULT 1, - schedule TEXT -- golang auth.Schedule + is_admin BOOLEAN DEFAULT 0 NOT NULL ); CREATE INDEX user_id ON user(id); -CREATE INDEX user_name ON user(name); +CREATE INDEX user_handle ON user(handle); CREATE TABLE credential( - user INTEGER NOT NULL, - data text NOT NULL, - FOREIGN KEY(user) REFERENCES user(id) ON DELETE CASCADE + user INTEGER NOT NULL, + data TEXT NOT NULL, + FOREIGN KEY(user) REFERENCES user(id) ON DELETE CASCADE ); CREATE INDEX credential_user ON credential(id); @@ -31,11 +33,17 @@ CREATE TABLE session( CREATE INDEX session_token ON session(token); - -CREATE TABLE sessions ( - token TEXT PRIMARY KEY, - data BLOB NOT NULL, - expiry REAL NOT NULL +CREATE TABLE log( + timestamp TEXT PRIMARY KEY, + user TEXT NOT NULL, + second_factor BOOLEAN NOT NULL, + failure VARCHAR(255), + error TEXT, + success boolean NOT NULL, + ip_address varchar(255) NOT NULL, + user_agent varchar(255) NOT NULL ); -CREATE INDEX sessions_expiry_idx ON sessions(expiry); \ No newline at end of file +CREATE INDEX log_timestamp_idx ON log(timestamp); +CREATE INDEX log_timestamp_error_idx ON log(timestamp,error); +CREATE INDEX log_timestamp_user_idx ON log(timestamp,user);