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", Description: "a custom greeting for the user",
Default: "", Default: "",
}, },
"admin": {
Type: "bool",
Description: "make this user an admin",
},
}, },
Action: func(cmd *command.Command) error { Action: func(cmd *command.Command) error {
config := cmd.Options["config"].ToValue().(string) config := cmd.Options["config"].ToValue().(string)
@ -80,6 +84,7 @@ var userAddCommand = &command.Command{
schedule := cmd.Options["schedule"].ToString() schedule := cmd.Options["schedule"].ToString()
ttl := cmd.Options["ttl"].ToString() ttl := cmd.Options["ttl"].ToString()
greeting := cmd.Options["greeting"].ToString() greeting := cmd.Options["greeting"].ToString()
admin := cmd.Options["admin"].ToValue().(bool)
data, err := os.ReadFile(config) data, err := os.ReadFile(config)
if err != nil { if err != nil {
@ -105,19 +110,16 @@ var userAddCommand = &command.Command{
return err return err
} }
var ttlDuration auth.Duration
if err := ttlDuration.UnmarshalDB(ttl); err != nil {
return err
}
user := &auth.User{ user := &auth.User{
Name: cmd.Arguments[0].ToString(), Name: cmd.Arguments[0].ToString(),
Password: string(password), Password: string(password),
Handle: cmd.Arguments[1].ToString(), Handle: cmd.Arguments[1].ToString(),
Greeting: greeting, Greeting: greeting,
TTL: ttlDuration, IsAdmin: admin,
} }
*user.TTL = auth.TTL(ttl)
if schedule != "" { if schedule != "" {
user.Schedule = &auth.UserSchedule{} user.Schedule = &auth.UserSchedule{}
if err := user.Schedule.UnmarshalDB([]byte(schedule)); err != nil { if err := user.Schedule.UnmarshalDB([]byte(schedule)); err != nil {

View File

@ -3,6 +3,7 @@
package hue package hue
import ( import (
"fmt"
"os" "os"
"git.rob.mx/nidito/chinampa" "git.rob.mx/nidito/chinampa"
@ -37,13 +38,18 @@ var setupHueCommand = &command.Command{
domain := cmd.Arguments[1].ToValue().(string) domain := cmd.Arguments[1].ToValue().(string)
logrus.Infof("Setting up with bridge at %s, app %s", ip, domain) 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, "ip": ip,
"username": "", "username": "",
"device": -1, "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) device := cmd.Arguments[2].ToValue().(string)
logrus.Infof("Testing bridge at %s, username %s, device %s", ip, username, device) 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, "ip": ip,
"username": username, "username": username,
"device": device, "device": device,
}) })
if err != nil {
return door.RequestToEnter(d, "test") 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 go 1.18
require ( 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/alexedwards/scs/v2 v2.5.0
github.com/amimof/huego v1.2.1 github.com/amimof/huego v1.2.1
github.com/go-webauthn/webauthn v0.6.0 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= 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-20230102065449-d9b257e145ce h1:fKG3wUdPgsviY2mE79vhXL4CalNdvhkL6vDAtdyVt0I=
git.rob.mx/nidito/chinampa v0.0.0-20221229190558-4eec8e55a1e6/go.mod h1:nQlQqIQ6UuP6spFFZvfVT1MhQJYEA7B3Y2EtM2Fha3Y= git.rob.mx/nidito/chinampa v0.0.0-20230102065449-d9b257e145ce/go.mod h1:obhWsLkUIlKJyhfa7uunrSs2O44JBqsegSAtAvY2LRM=
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=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 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/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 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 h1:kd36vsieclW4fZ4Vqii9DNU2+6ptWWtkp4OG0AXM8HE=
github.com/amimof/huego v1.2.1/go.mod h1:z1Sy7Rrdzmb+XsGHVEhODrRJRDq4RCFW7trCI5cKmeA= 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/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.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 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E=
github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 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/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/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/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.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 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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/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/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.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.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/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.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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/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/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.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.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 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M=
github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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-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-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.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 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 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= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=

View File

@ -10,7 +10,6 @@ import (
"net/http" "net/http"
"time" "time"
"git.rob.mx/nidito/puerta/internal/door"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
@ -21,25 +20,20 @@ import (
type AuthContext string type AuthContext string
const ( const (
ContextCookieName AuthContext = "_puerta" ContextCookieName AuthContext = "_puerta"
ContextSessionName AuthContext = "_rex" ContextUser AuthContext = "_user"
ContextUserName AuthContext = "auth-username"
ContextGreeting AuthContext = "auth-greeting"
ContextDoor AuthContext = "auth-door"
) )
type Manager struct { type Manager struct {
db db.Session db db.Session
door door.Door
wan *webauthn.WebAuthn wan *webauthn.WebAuthn
sess *scs.SessionManager 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 := scs.New()
sessionManager.Lifetime = 5 * time.Minute sessionManager.Lifetime = 5 * time.Minute
return &Manager{ return &Manager{
door: door,
db: db, db: db,
wan: wan, wan: wan,
sess: sessionManager, 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) { return func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
ctx := req.Context() ctxUser := req.Context().Value(ContextUser)
var user *User req = func() *http.Request {
if ctxUser := ctx.Value(ContextUserName); ctxUser == nil { if ctxUser != nil {
cookie, err := req.Cookie(string(ContextCookieName)) return req
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
} }
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(). q := am.db.SQL().
Select("s.token as token, ", "u.*"). Select("s.token as token, ", "u.*").
From("session as s"). From("session as s").
Join("user as u").On("s.user = u.id"). Join("user as u").On("s.user = u.id").
Where(db.Cond{"s.token": cookie.Value}) Where(db.Cond{"s.token": cookie.Value})
session := &SessionUser{}
if err := q.One(&session); err != nil { 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)) w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Secure; Path=/;", ContextCookieName, "", -1))
if redirect { return req
http.Redirect(w, req, "/login", http.StatusSeeOther) }
} else {
am.requestAuth(w, http.StatusUnauthorized) 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 { return req.WithContext(context.WithValue(req.Context(), ContextUser, &session.User))
logrus.Errorf("Denying access to %s: %s", session.User.Name, err) }()
am.requestAuth(w, http.StatusForbidden)
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)
}
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) 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") sd := am.sess.GetBytes(req.Context(), "wan-register")
if sd == nil { if sd == nil {
logrus.Infof("Starting webauthn registration for %s", user.Name) 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 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") sd := am.sess.GetBytes(req.Context(), "rex")
if sd == nil { if sd == nil {
logrus.Infof("Starting webauthn login flow for %s", user.Name) logrus.Infof("Starting webauthn login flow for %s", user.Name)

View File

@ -8,16 +8,14 @@ import (
"time" "time"
) )
type Duration time.Duration var DefaultTTL = TTL("30d")
func (d Duration) MarshalDB() (any, error) { type TTL string
return time.Duration(d).String(), nil
}
func (d *Duration) UnmarshalDB(value any) error { func (ttl TTL) ToDuration() (res time.Duration, err error) {
str := value.(string) suffix := ttl[len(ttl)-1]
suffix := str[len(str)-1]
toParse := string(ttl)
if suffix == 'd' || suffix == 'w' || suffix == 'M' { if suffix == 'd' || suffix == 'w' || suffix == 'M' {
multiplier := 1 multiplier := 1
switch suffix { switch suffix {
@ -28,29 +26,32 @@ func (d *Duration) UnmarshalDB(value any) error {
case 'M': case 'M':
multiplier = 24 * 7 * 30 multiplier = 24 * 7 * 30
default: 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] toParse = toParse[0 : len(toParse)-1]
days, err := strconv.Atoi(str) var days int
days, err = strconv.Atoi(toParse)
if err != nil { if err != nil {
return err return
} }
str = fmt.Sprintf("%dh", days*multiplier) toParse = fmt.Sprintf("%dh", days*multiplier)
} }
tmp, err := time.ParseDuration(str) res, err = time.ParseDuration(toParse)
if err != nil { return
return err
}
*d = Duration(tmp)
return nil
} }
func (d *Duration) FromNow() time.Time { func (ttl *TTL) FromNow() time.Time {
return time.Now().Add(time.Duration(*d)) d, _ := ttl.ToDuration()
return time.Now().Add(d)
} }
func (d *Duration) Seconds() int { func (ttl *TTL) Seconds() int {
return int(time.Duration(*d).Seconds()) d, _ := ttl.ToDuration()
return int(d.Seconds())
} }
// var _ = (db.Unmarshaler(&TTL{}))
// var _ = (db.Marshaler(&TTL{}))

View File

@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/upper/db/v4"
) )
func parseHour(src string) (float64, error) { func parseHour(src string) (float64, error) {
@ -40,13 +41,17 @@ type UserSchedule struct {
hours []float64 hours []float64
} }
func (d UserSchedule) MarshalDB() ([]byte, error) { func (d UserSchedule) MarshalDB() (any, error) {
return json.Marshal(d.src) 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 var v string
if err := json.Unmarshal(b, &v); err != nil { if err := json.Unmarshal(b.([]byte), &v); err != nil {
return err return err
} }
@ -104,3 +109,6 @@ func (sch *UserSchedule) AllowedAt(t time.Time) bool {
return true 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 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 { type User struct {
ID int `db:"id"` ID int `db:"id" json:"-"`
Handle string `db:"user"` Handle string `db:"user" json:"user"`
Name string `db:"name"` Name string `db:"name" json:"name"`
Password string `db:"password"` Password string `db:"password" json:"password"`
Schedule *UserSchedule `db:"schedule,omitempty"` Schedule *UserSchedule `db:"schedule,omitempty" json:"schedule"`
Expires *time.Time `db:"expires,omitempty"` Expires *time.Time `db:"expires,omitempty" json:"expires"`
Greeting string `db:"greeting"` Greeting string `db:"greeting" json:"greeting"`
TTL Duration `db:"max_ttl"` TTL *TTL `db:"max_ttl,omitempty" json:"max_ttl"`
Require2FA bool `db:"second_factor"` Require2FA bool `db:"second_factor" json:"second_factor"`
IsAdmin bool `db:"is_admin" json:"admin"`
credentials []*Credential credentials []*Credential
} }
@ -92,7 +102,7 @@ func (u *User) FetchCredentials(sess db.Session) error {
func (o *User) UnmarshalJSON(b []byte) error { func (o *User) UnmarshalJSON(b []byte) error {
type alias User type alias User
xo := &alias{TTL: Duration(30 * 24 * time.Hour)} xo := &alias{TTL: &DefaultTTL}
if err := json.Unmarshal(b, xo); err != nil { if err := json.Unmarshal(b, xo); err != nil {
return err return err
} }
@ -129,3 +139,5 @@ func (user *User) Login(password string) error {
return nil return nil
} }
var _ = db.Record(&User{})

View File

@ -13,9 +13,10 @@ import (
var ( var (
isOpening bool isOpening bool
statusMu sync.Mutex statusMu sync.Mutex
Active Door
) )
type newDoorFunc func(map[string]any) Door type newDoorFunc func(map[string]any) (Door, error)
var adapters = &struct { var adapters = &struct {
factories map[string]newDoorFunc factories map[string]newDoorFunc
@ -42,20 +43,20 @@ func setStatus(status bool) {
} }
// RequestToEnter opens the door unless it's already open or opening // 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() statusMu.Lock()
if isOpening { if isOpening {
defer statusMu.Unlock() 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 { if err != nil {
statusMu.Unlock() statusMu.Unlock()
return &DoorCommunicationError{"checking status", err} return &ErrorCommunication{"checking status", err}
} else if isOpen { } else if isOpen {
statusMu.Unlock() statusMu.Unlock()
return &DoorAlreadyOpen{} return &ErrorAlreadyOpen{}
} }
// okay, we're triggering an open and preventing others // 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) errors := make(chan error, 2)
done := make(chan bool) done := make(chan bool)
go door.Open(errors, done) go Active.Open(errors, done)
if err = <-errors; err != nil { if err = <-errors; err != nil {
setStatus(false) setStatus(false)
return &DoorCommunicationError{"opening", err} return &ErrorCommunication{"opening", err}
} }
logrus.Infof("Door opened for %s", username) logrus.Infof("Door opened for %s", username)
@ -94,16 +95,17 @@ func RequestToEnter(door Door, username string) error {
return nil return nil
} }
func NewDoor(config map[string]any) (Door, error) { func Connect(config map[string]any) (err error) {
adapterName, hasAdapter := config["kind"] adapterName, hasAdapter := config["kind"]
if !hasAdapter { if !hasAdapter {
return nil, fmt.Errorf("missing DOOR_ADAPTER") return fmt.Errorf("missing DOOR_ADAPTER")
} }
factory, exists := adapters.factories[adapterName.(string)] factory, exists := adapters.factories[adapterName.(string)]
if !exists { 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" "net/http"
) )
type DoorCommunicationError struct { type ErrorCommunication struct {
during string during string
err error err error
} }
func (err *DoorCommunicationError) Error() string { func (err *ErrorCommunication) Error() string {
return fmt.Sprintf("ould not get door status while %s: %s", err.during, err.err.Error()) 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 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" return "door is already open"
} }
func (err *DoorAlreadyOpen) Code() int { func (err *ErrorAlreadyOpen) Code() int {
return http.StatusPreconditionFailed 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) _register("hue", NewHue)
} }
func NewHue(config map[string]any) Door { func NewHue(config map[string]any) (Door, error) {
cfg := &HueConfig{ cfg := &HueConfig{
ip: config["ip"].(string), ip: config["ip"].(string),
@ -49,11 +49,11 @@ func NewHue(config map[string]any) Door {
if cfg.username != "" && cfg.device > -1 { if cfg.username != "" && cfg.device > -1 {
device, err := h.bridge.GetLight(cfg.device) device, err := h.bridge.GetLight(cfg.device)
if err != nil { if err != nil {
panic(err) return nil, err
} }
h.device = device h.device = device
} }
return h return h, nil
} }
func (h *Hue) Setup(domain string) error { func (h *Hue) Setup(domain string) error {

View File

@ -18,11 +18,11 @@ type mockDoor struct {
FailedToClose error FailedToClose error
} }
func NewMock(config map[string]any) Door { func NewMock(config map[string]any) (Door, error) {
logrus.Info("Initializing mock client") logrus.Info("Initializing mock client")
return &mockDoor{ return &mockDoor{
Status: false, Status: false,
} }, nil
} }
func (md *mockDoor) IsOpen() (bool, error) { func (md *mockDoor) IsOpen() (bool, error) {

View File

@ -24,12 +24,12 @@ type Wemo struct {
client *http.Client 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"]) logrus.Infof("Wemo client for %s starting", config["endpoint"])
return &Wemo{ return &Wemo{
endpoint: config["endpoint"].(string), endpoint: config["endpoint"].(string),
client: &http.Client{Timeout: 4 * time.Second}, client: &http.Client{Timeout: 4 * time.Second},
} }, nil
} }
const wemoBodyGet string = `<?xml version="1.0" encoding="utf-8"?> 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" "io/fs"
"log" "log"
"net/http" "net/http"
"time"
"git.rob.mx/nidito/puerta/internal/auth" "git.rob.mx/nidito/puerta/internal/auth"
"git.rob.mx/nidito/puerta/internal/door" "git.rob.mx/nidito/puerta/internal/door"
"git.rob.mx/nidito/puerta/internal/errors" "git.rob.mx/nidito/puerta/internal/errors"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"github.com/sirupsen/logrus"
"github.com/upper/db/v4" "github.com/upper/db/v4"
"github.com/upper/db/v4/adapter/sqlite" "github.com/upper/db/v4/adapter/sqlite"
) )
@ -24,6 +26,9 @@ var loginTemplate []byte
//go:embed index.html //go:embed index.html
var indexTemplate []byte var indexTemplate []byte
//go:embed admin.html
var adminTemplate []byte
//go:embed static/* //go:embed static/*
var staticFiles embed.FS 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) { func CORS(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Access-Control-Request-Method") != "" { if r.Header.Get("Access-Control-Request-Method") != "" {
// Set CORS headers // 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) { 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) message, code := errors.ToHTTP(err)
http.Error(w, message, code) http.Error(w, message, code)
return return
@ -75,7 +132,6 @@ func rex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
} }
var _db db.Session var _db db.Session
var _door door.Door
func Initialize(config *Config) (http.Handler, error) { func Initialize(config *Config) (http.Handler, error) {
router := httprouter.New() router := httprouter.New()
@ -94,8 +150,7 @@ func Initialize(config *Config) (http.Handler, error) {
return nil, err return nil, err
} }
_door, err = door.NewDoor(config.Adapter) if err := door.Connect(config.Adapter); err != nil {
if err != nil {
return nil, err return nil, err
} }
@ -111,7 +166,7 @@ func Initialize(config *Config) (http.Handler, error) {
return nil, err return nil, err
} }
am := auth.NewManager(wan, _door, _db) am := auth.NewManager(wan, _db)
serverRoot, err := fs.Sub(staticFiles, "static") serverRoot, err := fs.Sub(staticFiles, "static")
if err != nil { if err != nil {
@ -120,9 +175,16 @@ func Initialize(config *Config) (http.Handler, error) {
router.ServeFiles("/static/*filepath", http.FS(serverRoot)) router.ServeFiles("/static/*filepath", http.FS(serverRoot))
router.GET("/login", renderTemplate(loginTemplate)) 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/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 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'); workbox.loadModule('workbox-strategies');
self.addEventListener("install", event => { self.addEventListener("install", event => {
console.log("Service worker installed"); console.log("Service worker installed");
const urlsToCache = ["/", "app.js", "styles.css", "logo.svg"]; const urlsToCache = ["/login", "/", "index.css", "/index.js", "/login.js"];
event.waitUntil( event.waitUntil(
caches.open("pwa-assets") caches.open("pwa-assets")
.then(cache => { .then(cache => {
@ -22,7 +23,7 @@ self.addEventListener("activate", event => {
self.addEventListener('fetch', 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(); const cacheFirst = new workbox.strategies.CacheFirst();
event.respondWith(cacheFirst.handle({request: event.request})); event.respondWith(cacheFirst.handle({request: event.request}));
} }

View File

@ -1,22 +1,24 @@
CREATE TABLE user( CREATE TABLE user(
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL UNIQUE, handle VARCHAR(255) NOT NULL UNIQUE,
name TEXT NOT NULL,
password TEXT, password TEXT,
expires TEXT, -- datetime expires TEXT, -- datetime
greeting TEXT, 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, 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_id ON user(id);
CREATE INDEX user_name ON user(name); CREATE INDEX user_handle ON user(handle);
CREATE TABLE credential( CREATE TABLE credential(
user INTEGER NOT NULL, user INTEGER NOT NULL,
data text NOT NULL, data TEXT NOT NULL,
FOREIGN KEY(user) REFERENCES user(id) ON DELETE CASCADE FOREIGN KEY(user) REFERENCES user(id) ON DELETE CASCADE
); );
CREATE INDEX credential_user ON credential(id); CREATE INDEX credential_user ON credential(id);
@ -31,11 +33,17 @@ CREATE TABLE session(
CREATE INDEX session_token ON session(token); CREATE INDEX session_token ON session(token);
CREATE TABLE log(
CREATE TABLE sessions ( timestamp TEXT PRIMARY KEY,
token TEXT PRIMARY KEY, user TEXT NOT NULL,
data BLOB NOT NULL, second_factor BOOLEAN NOT NULL,
expiry REAL 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);