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"
@ -22,24 +21,19 @@ type AuthContext string
const (
ContextCookieName AuthContext = "_puerta"
ContextSessionName AuthContext = "_rex"
ContextUserName AuthContext = "auth-username"
ContextGreeting AuthContext = "auth-greeting"
ContextDoor AuthContext = "auth-door"
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,76 +95,94 @@ 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 {
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 value found in <%s>", req.Cookies())
if redirect {
http.Redirect(w, req, "/login", http.StatusTemporaryRedirect)
} else {
am.requestAuth(w, http.StatusUnauthorized)
}
return
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 {
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 req
}
return req.WithContext(context.WithValue(req.Context(), ContextUser, &session.User))
}()
handler(w, req, ps)
}
}
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
}
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)
user := req.Context().Value(ContextUser).(*User)
if !user.Require2FA {
handler(w, req, ps)
return
}
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)
return
}
if len(user.credentials) == 0 {
err = am.WebAuthnRegister(user, req)
err = am.WebAuthnRegister(req)
} else {
err = am.WebAuthnLogin(user, req)
err = am.WebAuthnLogin(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
}
@ -178,12 +190,31 @@ func (am *Manager) Protected(handler httprouter.Handle, redirect, enforce2FA boo
w.WriteHeader(http.StatusInternalServerError)
return
}
}
handler(w, req, ps)
}
})
}
func (am *Manager) WebAuthnRegister(user *User, req *http.Request) error {
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,21 +1,23 @@
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,
data TEXT NOT NULL,
FOREIGN KEY(user) REFERENCES user(id) ON DELETE CASCADE
);
@ -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);