some user rest crud, refactor middleware, start admin

This commit is contained in:
Roberto Hidalgo 2023-01-02 00:54:23 -06:00
parent 8e844748c9
commit bf7e3d7785
20 changed files with 673 additions and 186 deletions

21
.editorconfig Normal file
View File

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

View File

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

View File

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

2
go.mod
View File

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

17
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = `<?xml version="1.0" encoding="utf-8"?>

155
internal/server/admin.go Normal file
View File

@ -0,0 +1,155 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
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)
}

102
internal/server/admin.html Normal file
View File

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>puerta@nidi.to</title>
<link rel="stylesheet" href="https://cdn.rob.mx/css/fonts.css" />
<link rel="stylesheet" href="https://cdn.rob.mx/nidito/index.css" />
<link rel="stylesheet" href="/static/index.css" />
</head>
<body>
<header id="main-header">
<div class="container">
<h1>Puerta</h1>
<p>Admin</p>
</div>
</header>
<main class="container">
<section id="create-user">
<h2>Crear usuario</h2>
<form id="create-user" method="post" action="/api/user">
<label for="user">Handle</label>
<input name="handle" placeholder="joao" autocorrect="off"/>
<label for="name">Nombre</label>
<input name="name" placeholder="João Gilberto" />
<label for="greeting">Greeting</label>
<input name="greeting" placeholder="Olá Joãzinho!" />
<label for="password">Password</label>
<input type="password" name="password" />
<label for="schedule">Horarios</label>
<input type="text" name="schedule" placeholder="days=1-5 hours=8-20:35" autocorrect="off"/>
<label for="expires">Expires</label>
<input type="datetime-local" name="expires" placeholder="2023-01-01T00:00:00Z" />
<label for="max_ttl">TTL</label>
<input type="text" name="max_ttl" placeholder="30d" autocorrect="off"/>
<label for="admin">Admin?</label>
<input type="checkbox" name="is_admin" />
<label for="second_factor">Requiere 2FA?</label>
<input type="checkbox" name="second_factor" />
<button id="create-user-submit" type="submit">Crear</button>
</form>
</section>
<section id="user-list">
<h2>Usuarios</h2>
<ul></ul>
</section>
</main>
<script type="module" src="/static/admin.js"></script>
<template id="user-info-panel">
<li class="user-info-panel {{ if .IsAdmin }}user-info-panel-admin{{ end }}">
<header>
<h3><slot name="name">{{ .Name }}</h3>
<code><pre>{{ .Handle }}</pre></code>
<button class="user-edit">Modificar</button>
</header>
<div class="user-info-panel-details">
<label for="name">Nombre</label>
<input name="name" value="" placeholder="João Gilberto" />
<label for="greeting">Greeting</label>
<input name="greeting" placeholder="Olá Joãzinho!" />
<label for="password">Password</label>
<input type="password" name="password" />
<label for="schedule">Horarios</label>
<input type="text" name="schedule" placeholder="days=1-5 hours=8-20:35" autocorrect="off"/>
<label for="expires">Expires</label>
<input type="datetime-local" name="expires" placeholder="2023-01-01T00:00:00Z" />
<label for="ttl">TTL</label>
<input type="text" name="max_ttl" placeholder="30d" autocorrect="off"/>
<label for="admin">Admin?</label>
<input type="checkbox" name="is_admin" />
<label for="admin">Requiere 2FA?</label>
<input type="checkbox" name="second_factor" />
<button class="user-delete">Eliminar</button>
<button class="user-save">Guardar cambios</button>
</div>
</li>
</template>
</body>
</html>

View File

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

View File

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

View File

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

View File

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