refactor and nice stuff
This commit is contained in:
parent
63b9a753e8
commit
7e5dbed4f5
28
README.md
28
README.md
@ -0,0 +1,28 @@
|
|||||||
|
# la puerta de mi casa
|
||||||
|
|
||||||
|
A ridiculously elaborate rubegoldbergian contraption to exchange my guests' biometric data for my front door going _bzzzz_, built with go, css, html and javascript.
|
||||||
|
|
||||||
|
This project is:
|
||||||
|
|
||||||
|
- **highly insecure**: you should not run this at home,
|
||||||
|
- **very alpha**: to put it mildly,
|
||||||
|
- **poorly tested** by my guests and myself, so barely—if at all, and
|
||||||
|
- **truly magical** to see in action, when it does work.
|
||||||
|
|
||||||
|
## Web App
|
||||||
|
|
||||||
|
This is what my guests see. It's basically a login page where they enter credentials, and then a big button to open the door. My guests are required to authenticate with a [_passkeys_](https://passkey.org/) before opening the door, usually backed by a yubikey, TouchID or whatever android does.
|
||||||
|
|
||||||
|
A very simple admin page allows me to manage guests and see the entry log. Built with pochjs (plain-old css, html and js).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
The API runs [on my homelab](https://github.com/unRob/nidito/blob/main/services/puerta/puerta.nomad), serves the web app and interacts with my front door's buzzer. It's built with go and backed by SQLite.
|
||||||
|
|
||||||
|
### Adapters
|
||||||
|
|
||||||
|
Since the buzzer electrical setup is still not something i completely understand, I went around the issue by connecting the buzzer's power supply to a "smart" plug. Originally built it to control a [wemo mini smart plug](https://www.belkin.com/support-article/?articleNum=226110), but have since switched into using a [hue one](https://www.philips-hue.com/en-us/p/hue-smart-plug/046677552343) for no good reason other than the wemo's API is annoying.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
There's a small CLI tool to start the API, setup and test the Hue connection, and to add users (helpful during bootstrap).
|
@ -5,12 +5,11 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.rob.mx/nidito/chinampa"
|
"git.rob.mx/nidito/chinampa"
|
||||||
"git.rob.mx/nidito/chinampa/pkg/command"
|
"git.rob.mx/nidito/chinampa/pkg/command"
|
||||||
"git.rob.mx/nidito/puerta/internal/auth"
|
|
||||||
"git.rob.mx/nidito/puerta/internal/server"
|
"git.rob.mx/nidito/puerta/internal/server"
|
||||||
|
"git.rob.mx/nidito/puerta/internal/user"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/upper/db/v4/adapter/sqlite"
|
"github.com/upper/db/v4/adapter/sqlite"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@ -44,12 +43,14 @@ var userAddCommand = &command.Command{
|
|||||||
},
|
},
|
||||||
Options: command.Options{
|
Options: command.Options{
|
||||||
"config": {
|
"config": {
|
||||||
Type: "string",
|
Type: "string",
|
||||||
Default: "./config.joao.yaml",
|
Default: "./config.joao.yaml",
|
||||||
|
Description: "the config to read from",
|
||||||
},
|
},
|
||||||
"db": {
|
"db": {
|
||||||
Type: "string",
|
Type: "string",
|
||||||
Default: "./puerta.db",
|
Default: "./puerta.db",
|
||||||
|
Description: "the database to operate on",
|
||||||
},
|
},
|
||||||
"ttl": {
|
"ttl": {
|
||||||
Type: "string",
|
Type: "string",
|
||||||
@ -102,45 +103,50 @@ var userAddCommand = &command.Command{
|
|||||||
// Options: {},
|
// Options: {},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("could not open connection to db: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
password, err := bcrypt.GenerateFromPassword([]byte(cmd.Arguments[2].ToString()), bcrypt.DefaultCost)
|
password, err := bcrypt.GenerateFromPassword([]byte(cmd.Arguments[2].ToString()), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("could not hash password: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
user := &auth.User{
|
u := &user.User{
|
||||||
Name: cmd.Arguments[0].ToString(),
|
Name: cmd.Arguments[1].ToString(),
|
||||||
Password: string(password),
|
Password: string(password),
|
||||||
Handle: cmd.Arguments[1].ToString(),
|
Handle: cmd.Arguments[0].ToString(),
|
||||||
Greeting: greeting,
|
Greeting: greeting,
|
||||||
IsAdmin: admin,
|
IsAdmin: admin,
|
||||||
}
|
}
|
||||||
|
|
||||||
*user.TTL = auth.TTL(ttl)
|
if ttl != "" {
|
||||||
|
u.TTL = &user.TTL{}
|
||||||
|
if err := u.TTL.Scan(ttl); err != nil {
|
||||||
|
return fmt.Errorf("could not decode ttl %s: %s", ttl, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if schedule != "" {
|
if schedule != "" {
|
||||||
user.Schedule = &auth.UserSchedule{}
|
u.Schedule = &user.Schedule{}
|
||||||
if err := user.Schedule.UnmarshalDB([]byte(schedule)); err != nil {
|
if err := u.Schedule.Scan(schedule); err != nil {
|
||||||
return err
|
return fmt.Errorf("could not decode schedule %s: %s", schedule, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if expires != "" {
|
if expires != "" {
|
||||||
t, err := time.Parse(time.RFC3339, expires)
|
t := &user.UTCTime{}
|
||||||
if err != nil {
|
if err := t.Scan(expires); err != nil {
|
||||||
return err
|
return fmt.Errorf("could not decode expires %s: %s", expires, err)
|
||||||
}
|
}
|
||||||
user.Expires = &t
|
u.Expires = t
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := sess.Collection("user").Insert(user)
|
res, err := sess.Collection("user").Insert(u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to insert %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Infof("Created user %s with ID: %d", user.Name, res.ID())
|
logrus.Infof("Created user %s with ID: %d", u.Name, res.ID())
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
},
|
},
|
||||||
|
@ -56,6 +56,6 @@ var serverCommand = &command.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
logrus.Infof("Listening on port %d", cfg.HTTP.Listen)
|
logrus.Infof("Listening on port %d", cfg.HTTP.Listen)
|
||||||
return http.ListenAndServe(fmt.Sprintf(":%d", cfg.HTTP.Listen), router)
|
return http.ListenAndServe(fmt.Sprintf("localhost:%d", cfg.HTTP.Listen), router)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
name: Casa de Alguien
|
name: Casa de alguien
|
||||||
|
|
||||||
adapter:
|
adapter:
|
||||||
kind: dry-run
|
kind: dry-run
|
||||||
ip: 192.168.1.256
|
# but really
|
||||||
username: nobody
|
# kind: hue
|
||||||
device: -1
|
# username: some-hue-bridge-key
|
||||||
|
# ip: 192.168.0.256 # the hue bridge's ip
|
||||||
|
# device: 53 # the device number
|
||||||
|
# see `puerta hue setup`
|
||||||
|
|
||||||
http:
|
http:
|
||||||
listen: 8000
|
listen: "localhost:8080"
|
||||||
domain: localhost
|
origin: http://localhost:8080
|
||||||
|
|
||||||
db: ./puerta.db
|
|
||||||
|
@ -7,6 +7,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.rob.mx/nidito/puerta/internal/constants"
|
||||||
|
"git.rob.mx/nidito/puerta/internal/errors"
|
||||||
|
"git.rob.mx/nidito/puerta/internal/user"
|
||||||
"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"
|
||||||
@ -14,38 +17,26 @@ import (
|
|||||||
"github.com/upper/db/v4"
|
"github.com/upper/db/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthContext string
|
var _db db.Session
|
||||||
|
var _wan *webauthn.WebAuthn
|
||||||
|
var _sess *scs.SessionManager
|
||||||
|
|
||||||
const (
|
func Initialize(wan *webauthn.WebAuthn, db db.Session) {
|
||||||
ContextCookieName AuthContext = "_puerta"
|
|
||||||
ContextUser AuthContext = "_user"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Manager struct {
|
|
||||||
db db.Session
|
|
||||||
wan *webauthn.WebAuthn
|
|
||||||
sess *scs.SessionManager
|
|
||||||
}
|
|
||||||
|
|
||||||
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{
|
_db = db
|
||||||
db: db,
|
_wan = wan
|
||||||
wan: wan,
|
|
||||||
sess: sessionManager,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *Manager) Route(router http.Handler) http.Handler {
|
func Route(router http.Handler) http.Handler {
|
||||||
return am.sess.LoadAndSave(router)
|
return _sess.LoadAndSave(router)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *Manager) requestAuth(w http.ResponseWriter, status int) {
|
func requestAuth(w http.ResponseWriter, status int) {
|
||||||
http.Error(w, http.StatusText(status), status)
|
http.Error(w, http.StatusText(status), status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *Manager) NewSession(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
func LoginHandler(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||||
err := req.ParseForm()
|
err := req.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
@ -55,9 +46,9 @@ func (am *Manager) NewSession(w http.ResponseWriter, req *http.Request, ps httpr
|
|||||||
username := req.FormValue("user")
|
username := req.FormValue("user")
|
||||||
password := req.FormValue("password")
|
password := req.FormValue("password")
|
||||||
|
|
||||||
user := &User{}
|
user := &user.User{}
|
||||||
if err := am.db.Get(user, db.Cond{"name": username}); err != nil {
|
if err := _db.Get(user, db.Cond{"name": username}); err != nil {
|
||||||
err := &InvalidCredentials{code: http.StatusForbidden, reason: fmt.Sprintf("User not found for name: %s (%s)", username, err)}
|
err := &errors.InvalidCredentials{Status: http.StatusForbidden, Reason: fmt.Sprintf("User not found for name: %s (%s)", username, err)}
|
||||||
err.Log()
|
err.Log()
|
||||||
http.Error(w, err.Error(), err.Code())
|
http.Error(w, err.Error(), err.Code())
|
||||||
return
|
return
|
||||||
@ -66,7 +57,7 @@ func (am *Manager) NewSession(w http.ResponseWriter, req *http.Request, ps httpr
|
|||||||
if err := user.Login(password); err != nil {
|
if err := user.Login(password); err != nil {
|
||||||
code := http.StatusBadRequest
|
code := http.StatusBadRequest
|
||||||
status := http.StatusText(code)
|
status := http.StatusText(code)
|
||||||
if err, ok := err.(InvalidCredentials); ok {
|
if err, ok := err.(errors.InvalidCredentials); ok {
|
||||||
code = err.Code()
|
code = err.Code()
|
||||||
status = err.Error()
|
status = err.Error()
|
||||||
err.Log()
|
err.Log()
|
||||||
@ -75,13 +66,13 @@ func (am *Manager) NewSession(w http.ResponseWriter, req *http.Request, ps httpr
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := NewSession(user, am.db.Collection("session"))
|
sess, err := NewSession(user, _db.Collection("session"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Could not create a session: %s", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Could not create a session: %s", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Path=/;", ContextCookieName, sess.Token, user.TTL.Seconds()))
|
w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Path=/;", constants.ContextCookieName, sess.Token, user.TTL.Seconds()))
|
||||||
|
|
||||||
logrus.Infof("Created session for %s", user.Name)
|
logrus.Infof("Created session for %s", user.Name)
|
||||||
|
|
||||||
|
@ -1,57 +0,0 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var DefaultTTL = TTL("30d")
|
|
||||||
|
|
||||||
type TTL string
|
|
||||||
|
|
||||||
func (ttl TTL) ToDuration() (res time.Duration, err error) {
|
|
||||||
if ttl == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
suffix := ttl[len(ttl)-1]
|
|
||||||
|
|
||||||
toParse := string(ttl)
|
|
||||||
if suffix == 'd' || suffix == 'w' || suffix == 'M' {
|
|
||||||
multiplier := 1
|
|
||||||
switch suffix {
|
|
||||||
case 'd':
|
|
||||||
multiplier = 24
|
|
||||||
case 'w':
|
|
||||||
multiplier = 24 * 7
|
|
||||||
case 'M':
|
|
||||||
multiplier = 24 * 7 * 30
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("unknown suffix for time duration %s", string(suffix))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toParse = toParse[0 : len(toParse)-1]
|
|
||||||
var days int
|
|
||||||
days, err = strconv.Atoi(toParse)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toParse = fmt.Sprintf("%dh", days*multiplier)
|
|
||||||
}
|
|
||||||
res, err = time.ParseDuration(toParse)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ttl *TTL) FromNow() time.Time {
|
|
||||||
d, _ := ttl.ToDuration()
|
|
||||||
return time.Now().Add(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ttl *TTL) Seconds() int {
|
|
||||||
d, _ := ttl.ToDuration()
|
|
||||||
return int(d.Seconds())
|
|
||||||
}
|
|
@ -1,68 +0,0 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AuthError interface {
|
|
||||||
Error() string
|
|
||||||
Code() int
|
|
||||||
Log()
|
|
||||||
}
|
|
||||||
|
|
||||||
type InvalidCredentials struct {
|
|
||||||
code int
|
|
||||||
reason string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (err InvalidCredentials) Error() string {
|
|
||||||
return "Usuario o contraseña desconocidos"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (err InvalidCredentials) Log() {
|
|
||||||
logrus.Error(err.reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (err InvalidCredentials) Code() int {
|
|
||||||
return err.code
|
|
||||||
}
|
|
||||||
|
|
||||||
type WebAuthFlowChallenge struct {
|
|
||||||
flow string
|
|
||||||
data any
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c WebAuthFlowChallenge) Error() string {
|
|
||||||
b, err := json.Marshal(map[string]any{"webauthn": c.flow, "data": c.data})
|
|
||||||
if err != nil {
|
|
||||||
logrus.Errorf("Could not marshal data: %s", err)
|
|
||||||
logrus.Errorf("data: %s", c.data)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c WebAuthFlowChallenge) Header() string {
|
|
||||||
b, err := json.Marshal(c.data)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Errorf("Could not marshal data: %s", err)
|
|
||||||
logrus.Errorf("data: %s", c.data)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.flow + " " + base64.StdEncoding.EncodeToString([]byte(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c WebAuthFlowChallenge) Log() {
|
|
||||||
logrus.Error("responding with webauthn challenge")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c WebAuthFlowChallenge) Code() int {
|
|
||||||
return 418
|
|
||||||
}
|
|
@ -7,59 +7,63 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"git.rob.mx/nidito/puerta/internal/constants"
|
||||||
|
"git.rob.mx/nidito/puerta/internal/errors"
|
||||||
|
"git.rob.mx/nidito/puerta/internal/user"
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/upper/db/v4"
|
"github.com/upper/db/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (am *Manager) withUser(handler httprouter.Handle) httprouter.Handle {
|
func 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) {
|
||||||
ctxUser := req.Context().Value(ContextUser)
|
u := user.FromContext(req)
|
||||||
req = func() *http.Request {
|
if u != nil {
|
||||||
if ctxUser != nil {
|
handler(w, req, ps)
|
||||||
return req
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, err := req.Cookie(string(ContextCookieName))
|
req = func() *http.Request {
|
||||||
|
cookie, err := req.Cookie(string(constants.ContextCookieName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Debugf("no cookie for user found in jar <%s>", req.Cookies())
|
logrus.Debugf("no cookie for user found in jar <%s>", req.Cookies())
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
session := &SessionUser{}
|
session := &SessionUser{}
|
||||||
q := am.db.SQL().
|
q := _db.SQL().
|
||||||
Select("s.token as token, ", "u.*").
|
Select("s.token as token, s.expires as expires", "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})
|
||||||
|
|
||||||
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)
|
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=/;", constants.ContextCookieName, "", -1))
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.Expired() {
|
if session.Expired() || session.User.Expired() {
|
||||||
logrus.Debugf("expired cookie found in DB for jar <%s>", req.Cookies())
|
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))
|
w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Secure; Path=/;", constants.ContextCookieName, "", -1))
|
||||||
err := am.db.Collection("session").Find(db.Cond{"token": cookie.Value}).Delete()
|
err := _db.Collection("session").Find(db.Cond{"token": cookie.Value}).Delete()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorf("could not purge expired session from DB: %s", err)
|
logrus.Errorf("could not purge expired session from DB: %s", err)
|
||||||
}
|
}
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
return req.WithContext(context.WithValue(req.Context(), ContextUser, &session.User))
|
return req.WithContext(context.WithValue(req.Context(), constants.ContextUser, &session.User))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
handler(w, req, ps)
|
handler(w, req, ps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *Manager) RequireAuth(handler httprouter.Handle) httprouter.Handle {
|
func RequireAuth(handler httprouter.Handle) httprouter.Handle {
|
||||||
return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
return withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||||
if req.Context().Value(ContextUser) == nil {
|
if req.Context().Value(constants.ContextUser) == nil {
|
||||||
am.requestAuth(w, http.StatusUnauthorized)
|
requestAuth(w, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,9 +71,9 @@ func (am *Manager) RequireAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *Manager) RequireAuthOrRedirect(handler httprouter.Handle, target string) httprouter.Handle {
|
func RequireAuthOrRedirect(handler httprouter.Handle, target string) httprouter.Handle {
|
||||||
return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
return withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||||
if req.Context().Value(ContextUser) == nil {
|
if req.Context().Value(constants.ContextUser) == nil {
|
||||||
http.Redirect(w, req, target, http.StatusTemporaryRedirect)
|
http.Redirect(w, req, target, http.StatusTemporaryRedirect)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -78,15 +82,15 @@ func (am *Manager) RequireAuthOrRedirect(handler httprouter.Handle, target strin
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *Manager) RegisterSecondFactor() httprouter.Handle {
|
func RegisterSecondFactor() httprouter.Handle {
|
||||||
return am.RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
return RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||||
user := req.Context().Value(ContextUser).(*User)
|
u := user.FromContext(req)
|
||||||
if !user.Require2FA {
|
if !u.Require2FA {
|
||||||
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
|
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := am.WebAuthnFinishRegistration(req)
|
err := webAuthnFinishRegistration(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorf("Failed during webauthn flow: %s", err.Error())
|
logrus.Errorf("Failed during webauthn flow: %s", err.Error())
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
@ -96,31 +100,30 @@ func (am *Manager) RegisterSecondFactor() httprouter.Handle {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *Manager) Enforce2FA(handler httprouter.Handle) httprouter.Handle {
|
func Enforce2FA(handler httprouter.Handle) httprouter.Handle {
|
||||||
return am.RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
return RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||||
user := req.Context().Value(ContextUser).(*User)
|
u := user.FromContext(req)
|
||||||
if !user.Require2FA {
|
if !u.Require2FA {
|
||||||
handler(w, req, ps)
|
handler(w, req, ps)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Debug("Enforcing 2fa for request")
|
logrus.Debug("Enforcing 2fa for request")
|
||||||
var err error
|
if err := u.FetchCredentials(_db); err != nil {
|
||||||
err = user.FetchCredentials(am.db)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Errorf("Failed fetching credentials: %s", err.Error())
|
logrus.Errorf("Failed fetching credentials: %s", err.Error())
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(user.credentials) == 0 {
|
var flow func(*http.Request) error
|
||||||
err = am.WebAuthnBeginRegistration(req)
|
if !u.HasCredentials() {
|
||||||
|
flow = webAuthnBeginRegistration
|
||||||
} else {
|
} else {
|
||||||
err = am.WebAuthnLogin(req)
|
flow = webAuthnLogin
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err := flow(req); err != nil {
|
||||||
if wafc, ok := err.(WebAuthFlowChallenge); ok {
|
if wafc, ok := err.(errors.WebAuthFlowChallenge); ok {
|
||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
w.Header().Add("content-type", "application/json")
|
w.Header().Add("content-type", "application/json")
|
||||||
w.Header().Add("webauthn", wafc.Header())
|
w.Header().Add("webauthn", wafc.Header())
|
||||||
@ -132,16 +135,20 @@ func (am *Manager) Enforce2FA(handler httprouter.Handle) httprouter.Handle {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defer am.sess.RenewToken(req.Context())
|
defer func() {
|
||||||
|
if err := _sess.RenewToken(req.Context()); err != nil {
|
||||||
|
logrus.Errorf("could not renew token")
|
||||||
|
}
|
||||||
|
}()
|
||||||
handler(w, req, ps)
|
handler(w, req, ps)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *Manager) RequireAdmin(handler httprouter.Handle) httprouter.Handle {
|
func RequireAdmin(handler httprouter.Handle) httprouter.Handle {
|
||||||
return am.RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
return RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||||
user := req.Context().Value(ContextUser).(*User)
|
user := req.Context().Value(constants.ContextUser).(*user.User)
|
||||||
if !user.IsAdmin {
|
if !user.IsAdmin {
|
||||||
am.requestAuth(w, http.StatusUnauthorized)
|
requestAuth(w, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler(w, req, ps)
|
handler(w, req, ps)
|
||||||
|
@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.rob.mx/nidito/puerta/internal/user"
|
||||||
"github.com/upper/db/v4"
|
"github.com/upper/db/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -48,22 +49,22 @@ type Session struct {
|
|||||||
Expires time.Time `db:"expires"`
|
Expires time.Time `db:"expires"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionUser struct {
|
|
||||||
Token string `db:"token"`
|
|
||||||
UserID int `db:"user"`
|
|
||||||
Expires time.Time `db:"expires"`
|
|
||||||
User `db:",inline"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Session) Store(sess db.Session) db.Store {
|
func (s *Session) Store(sess db.Session) db.Store {
|
||||||
return sess.Collection("session")
|
return sess.Collection("session")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) Expired() bool {
|
type SessionUser struct {
|
||||||
return s.Expires.Before(time.Now())
|
Token string `db:"token"`
|
||||||
|
UserID int `db:"user"`
|
||||||
|
Expires time.Time `db:"expires"`
|
||||||
|
user.User `db:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSession(user *User, table db.Collection) (*Session, error) {
|
func (s *SessionUser) Expired() bool {
|
||||||
|
return s.Expires.After(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSession(user *user.User, table db.Collection) (*Session, error) {
|
||||||
sess := &Session{
|
sess := &Session{
|
||||||
Token: NewToken(),
|
Token: NewToken(),
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
|
@ -10,6 +10,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.rob.mx/nidito/puerta/internal/errors"
|
||||||
|
"git.rob.mx/nidito/puerta/internal/user"
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
@ -20,10 +22,10 @@ const SessionNameWANAuth = "wan-auth"
|
|||||||
const SessionNameWANRegister = "wan-register"
|
const SessionNameWANRegister = "wan-register"
|
||||||
const HeaderNameWAN = "webauthn"
|
const HeaderNameWAN = "webauthn"
|
||||||
|
|
||||||
func (am *Manager) WebAuthnBeginRegistration(req *http.Request) error {
|
func webAuthnBeginRegistration(req *http.Request) error {
|
||||||
user := UserFromContext(req)
|
user := user.FromContext(req)
|
||||||
logrus.Infof("Starting webauthn registration for %s", user.Name)
|
logrus.Infof("Starting webauthn registration for %s", user.Name)
|
||||||
options, sessionData, err := am.wan.BeginRegistration(user)
|
options, sessionData, err := _wan.BeginRegistration(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("error starting webauthn: %s", err)
|
err = fmt.Errorf("error starting webauthn: %s", err)
|
||||||
logrus.Error(err)
|
logrus.Error(err)
|
||||||
@ -35,13 +37,13 @@ func (am *Manager) WebAuthnBeginRegistration(req *http.Request) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
am.sess.Put(req.Context(), SessionNameWANRegister, b.Bytes())
|
_sess.Put(req.Context(), SessionNameWANRegister, b.Bytes())
|
||||||
return WebAuthFlowChallenge{"register", &options}
|
return errors.WebAuthFlowChallenge{Flow: "register", Data: &options}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *Manager) WebAuthnFinishRegistration(req *http.Request) error {
|
func webAuthnFinishRegistration(req *http.Request) error {
|
||||||
user := UserFromContext(req)
|
u := user.FromContext(req)
|
||||||
sd := am.sess.PopBytes(req.Context(), SessionNameWANRegister)
|
sd := _sess.PopBytes(req.Context(), SessionNameWANRegister)
|
||||||
if sd == nil {
|
if sd == nil {
|
||||||
return fmt.Errorf("error finishing webauthn registration: no session found for user")
|
return fmt.Errorf("error finishing webauthn registration: no session found for user")
|
||||||
}
|
}
|
||||||
@ -52,7 +54,7 @@ func (am *Manager) WebAuthnFinishRegistration(req *http.Request) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cred, err := am.wan.FinishRegistration(user, sessionData, req)
|
cred, err := _wan.FinishRegistration(u, sessionData, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error finishing webauthn registration: %s", err)
|
return fmt.Errorf("error finishing webauthn registration: %s", err)
|
||||||
}
|
}
|
||||||
@ -61,22 +63,22 @@ func (am *Manager) WebAuthnFinishRegistration(req *http.Request) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error encoding webauthn credential for storage: %s", err)
|
return fmt.Errorf("error encoding webauthn credential for storage: %s", err)
|
||||||
}
|
}
|
||||||
credential := &Credential{
|
credential := &user.Credential{
|
||||||
UserID: user.ID,
|
UserID: u.ID,
|
||||||
Data: string(data),
|
Data: string(data),
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = am.db.Collection("credential").Insert(credential)
|
_, err = _db.Collection("credential").Insert(credential)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *Manager) WebAuthnLogin(req *http.Request) error {
|
func webAuthnLogin(req *http.Request) error {
|
||||||
user := UserFromContext(req)
|
user := user.FromContext(req)
|
||||||
sd := am.sess.PopBytes(req.Context(), SessionNameWANAuth)
|
sd := _sess.PopBytes(req.Context(), SessionNameWANAuth)
|
||||||
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)
|
||||||
|
|
||||||
options, sessionData, err := am.wan.BeginLogin(user)
|
options, sessionData, err := _wan.BeginLogin(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error starting webauthn login: %s", err)
|
return fmt.Errorf("error starting webauthn login: %s", err)
|
||||||
}
|
}
|
||||||
@ -86,9 +88,9 @@ func (am *Manager) WebAuthnLogin(req *http.Request) error {
|
|||||||
return fmt.Errorf("could not encode json: %s", err)
|
return fmt.Errorf("could not encode json: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
am.sess.Put(req.Context(), SessionNameWANAuth, b.Bytes())
|
_sess.Put(req.Context(), SessionNameWANAuth, b.Bytes())
|
||||||
|
|
||||||
return WebAuthFlowChallenge{"login", &options}
|
return errors.WebAuthFlowChallenge{Flow: "login", Data: &options}
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessionData webauthn.SessionData
|
var sessionData webauthn.SessionData
|
||||||
@ -112,10 +114,10 @@ func (am *Manager) WebAuthnLogin(req *http.Request) error {
|
|||||||
return fmt.Errorf("could not parse webauthn request into protocol: %w", err)
|
return fmt.Errorf("could not parse webauthn request into protocol: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = am.wan.ValidateLogin(user, sessionData, response)
|
_, err = _wan.ValidateLogin(user, sessionData, response)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *Manager) Cleanup() error {
|
func Cleanup() error {
|
||||||
return am.db.Collection("session").Find(db.Cond{"Expires": db.Before(time.Now())}).Delete()
|
return _db.Collection("session").Find(db.Cond{"Expires": db.Before(time.Now())}).Delete()
|
||||||
}
|
}
|
||||||
|
10
internal/constants/contstants.go
Normal file
10
internal/constants/contstants.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||||
|
package constants
|
||||||
|
|
||||||
|
type AuthContext string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContextCookieName AuthContext = "_puerta"
|
||||||
|
ContextUser AuthContext = "_user"
|
||||||
|
)
|
@ -2,6 +2,13 @@
|
|||||||
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||||
package errors
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
type HTTPError interface {
|
type HTTPError interface {
|
||||||
Error() string
|
Error() string
|
||||||
Code() int
|
Code() int
|
||||||
@ -13,3 +20,61 @@ func ToHTTP(err error) (string, int) {
|
|||||||
}
|
}
|
||||||
return err.Error(), 500
|
return err.Error(), 500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthError interface {
|
||||||
|
Error() string
|
||||||
|
Code() int
|
||||||
|
Log()
|
||||||
|
}
|
||||||
|
|
||||||
|
type InvalidCredentials struct {
|
||||||
|
Status int
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err InvalidCredentials) Error() string {
|
||||||
|
return "Usuario o contraseña desconocidos"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err InvalidCredentials) Log() {
|
||||||
|
logrus.Error(err.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err InvalidCredentials) Code() int {
|
||||||
|
return err.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebAuthFlowChallenge struct {
|
||||||
|
Flow string
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c WebAuthFlowChallenge) Error() string {
|
||||||
|
b, err := json.Marshal(map[string]any{"webauthn": c.Flow, "data": c.Data})
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("Could not marshal data: %s", err)
|
||||||
|
logrus.Errorf("data: %s", c.Data)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c WebAuthFlowChallenge) Header() string {
|
||||||
|
b, err := json.Marshal(c.Data)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("Could not marshal data: %s", err)
|
||||||
|
logrus.Errorf("data: %s", c.Data)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Flow + " " + base64.StdEncoding.EncodeToString([]byte(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c WebAuthFlowChallenge) Log() {
|
||||||
|
logrus.Error("responding with webauthn challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c WebAuthFlowChallenge) Code() int {
|
||||||
|
return 418
|
||||||
|
}
|
||||||
|
@ -6,7 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.rob.mx/nidito/puerta/internal/auth"
|
"git.rob.mx/nidito/puerta/internal/user"
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/upper/db/v4"
|
"github.com/upper/db/v4"
|
||||||
@ -31,7 +31,7 @@ func writeJSON(w http.ResponseWriter, data any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listUsers(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func listUsers(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
users := []*auth.User{}
|
users := []*user.User{}
|
||||||
if err := _db.Collection("user").Find().All(&users); err != nil {
|
if err := _db.Collection("user").Find().All(&users); err != nil {
|
||||||
sendError(w, err)
|
sendError(w, err)
|
||||||
return
|
return
|
||||||
@ -40,37 +40,37 @@ func listUsers(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|||||||
writeJSON(w, users)
|
writeJSON(w, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
func userFromRequest(r *http.Request, user *auth.User) (*auth.User, error) {
|
func userFromRequest(r *http.Request, u *user.User) (*user.User, error) {
|
||||||
dec := json.NewDecoder(r.Body)
|
dec := json.NewDecoder(r.Body)
|
||||||
res := &auth.User{}
|
res := &user.User{}
|
||||||
if err := dec.Decode(&res); err != nil {
|
if err := dec.Decode(&res); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
logrus.Debugf("Unserialized user data: %v", res)
|
logrus.Debugf("Unserialized user data: %v", res)
|
||||||
|
|
||||||
if user == nil {
|
if u == nil {
|
||||||
user = &auth.User{
|
u = &user.User{
|
||||||
Handle: res.Handle,
|
Handle: res.Handle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
user.Name = res.Name
|
u.Name = res.Name
|
||||||
user.Expires = res.Expires
|
u.Expires = res.Expires
|
||||||
user.Greeting = res.Greeting
|
u.Greeting = res.Greeting
|
||||||
user.IsAdmin = res.IsAdmin
|
u.IsAdmin = res.IsAdmin
|
||||||
user.Require2FA = res.Require2FA
|
u.Require2FA = res.Require2FA
|
||||||
user.Schedule = res.Schedule
|
u.Schedule = res.Schedule
|
||||||
user.TTL = res.TTL
|
u.TTL = res.TTL
|
||||||
|
|
||||||
if res.Password != "" {
|
if res.Password != "" {
|
||||||
password, err := bcrypt.GenerateFromPassword([]byte(res.Password), bcrypt.DefaultCost)
|
password, err := bcrypt.GenerateFromPassword([]byte(res.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
user.Password = string(password)
|
u.Password = string(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, nil
|
return u, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,10 +90,10 @@ func createUser(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getUser(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func getUser(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
var user *auth.User
|
var user *user.User
|
||||||
idString := params.ByName("id")
|
idString := params.ByName("id")
|
||||||
|
|
||||||
if err := _db.Collection("user").Find(db.Cond{"handle": idString}).One(&user); err != nil {
|
if err := _db.Get(user, db.Cond{"handle": idString}); err != nil {
|
||||||
sendError(w, err)
|
sendError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -102,8 +102,9 @@ func getUser(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUser(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func updateUser(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
var user *auth.User
|
logrus.Infof("updating user: %s", params.ByName("id"))
|
||||||
if err := _db.Collection("user").Find(db.Cond{"handle": params.ByName("id")}).One(user); err != nil {
|
var user *user.User
|
||||||
|
if err := _db.Get(user, db.Cond{"handle": params.ByName("id")}); err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -11,31 +11,26 @@
|
|||||||
<link rel="stylesheet" href="/static/index.css" />
|
<link rel="stylesheet" href="/static/index.css" />
|
||||||
<style>
|
<style>
|
||||||
#user-list {
|
#user-list {
|
||||||
display: flex;
|
display: grid;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
grid-auto-rows: minmax(100px, auto);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main-nav a {
|
#main-nav a {
|
||||||
color: #fff
|
color: #fff
|
||||||
}
|
}
|
||||||
|
|
||||||
user-info-panel {
|
table {
|
||||||
padding: .5em;
|
|
||||||
border: 1px solid #c11145;
|
|
||||||
border-radius: 5px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 30%;
|
|
||||||
margin-right: 3%;
|
|
||||||
}
|
|
||||||
|
|
||||||
rex-record {
|
|
||||||
display:table-row;
|
|
||||||
}
|
|
||||||
|
|
||||||
td, th {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rex-record {
|
||||||
|
font-size: .8em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -57,22 +52,88 @@
|
|||||||
<style>
|
<style>
|
||||||
@import "/static/index.css";
|
@import "/static/index.css";
|
||||||
|
|
||||||
|
:host {
|
||||||
|
border: 1px solid #c11145;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: all ease-in-out .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(.editing) {
|
||||||
|
position: absolute;
|
||||||
|
background-color: #f9f2f4;
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0px 1px 4px, 2px 2px #c11145;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: "Aestetico", sans-serif;
|
||||||
|
border-width: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h3 {
|
||||||
|
background: #c46e87;
|
||||||
|
margin: 0;
|
||||||
|
padding: .5em .2em;
|
||||||
|
color: #fff
|
||||||
|
}
|
||||||
|
|
||||||
|
header .user-info-meta {
|
||||||
|
padding: .5em .2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header button {
|
||||||
|
float: right;
|
||||||
|
border-radius: 100%;
|
||||||
|
background-color: rgba(255,255,255,.5);
|
||||||
|
margin: .5em;
|
||||||
|
font-size: 1em;
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
}
|
||||||
|
header button:hover {
|
||||||
|
background-color: rgba(255,255,255,.8)
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
padding: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#actions {
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
padding: .5em 0;
|
||||||
|
margin: .5em 0em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-delete {
|
||||||
|
font-size: 1.2em;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-save {
|
||||||
|
float: right;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
.user-info-panel {
|
.user-info-panel {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<li class="user-info-panel">
|
<li class="user-info-panel">
|
||||||
<header>
|
<header>
|
||||||
|
<button class="user-edit">✎</button>
|
||||||
<h3>Alguien</h3>
|
<h3>Alguien</h3>
|
||||||
<code><pre>alguien</pre></code>
|
<div class="user-info-meta">
|
||||||
<button class="user-edit">Modificar</button>
|
<code>alguien</code>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<form action="/api/user/:id" class="user-info-panel-details hidden">
|
<form action="/api/user/:id" class="user-info-panel-details hidden">
|
||||||
<label for="name">Nombre</label>
|
<label for="name">Nombre</label>
|
||||||
<input name="name" value="" placeholder="João Gilberto" required />
|
<input name="name" value="" placeholder="João Gilberto" required />
|
||||||
|
|
||||||
<label for="greeting">Greeting</label>
|
<label for="greeting">Saludo</label>
|
||||||
<input name="greeting" placeholder="Olá Joãzinho!" />
|
<input name="greeting" placeholder="Olá Joãzinho!" />
|
||||||
|
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
@ -87,14 +148,18 @@
|
|||||||
<label for="ttl">TTL</label>
|
<label for="ttl">TTL</label>
|
||||||
<input type="text" name="max_ttl" placeholder="30d" autocorrect="off"/>
|
<input type="text" name="max_ttl" placeholder="30d" autocorrect="off"/>
|
||||||
|
|
||||||
<label for="admin">Admin?</label>
|
<div>
|
||||||
<input type="checkbox" name="is_admin" />
|
<input type="checkbox" name="is_admin" /><label for="admin">Admin?</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="admin">Requiere 2FA?</label>
|
<div>
|
||||||
<input type="checkbox" name="second_factor" />
|
<input type="checkbox" name="second_factor" /><label for="admin">Requiere 2FA?</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="user-delete">Eliminar</button>
|
<div id="actions">
|
||||||
<button class="user-save">Guardar cambios</button>
|
<button class="user-delete">Eliminar</button>
|
||||||
|
<button class="user-save">Guardar cambios</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@ -109,7 +174,7 @@
|
|||||||
<label for="name">Nombre</label>
|
<label for="name">Nombre</label>
|
||||||
<input name="name" placeholder="João Gilberto" required />
|
<input name="name" placeholder="João Gilberto" required />
|
||||||
|
|
||||||
<label for="greeting">Greeting</label>
|
<label for="greeting">Saludo</label>
|
||||||
<input name="greeting" placeholder="Olá Joãzinho!" />
|
<input name="greeting" placeholder="Olá Joãzinho!" />
|
||||||
|
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
@ -124,11 +189,13 @@
|
|||||||
<label for="max_ttl">TTL</label>
|
<label for="max_ttl">TTL</label>
|
||||||
<input type="text" name="max_ttl" placeholder="30d" autocorrect="off"/>
|
<input type="text" name="max_ttl" placeholder="30d" autocorrect="off"/>
|
||||||
|
|
||||||
<label for="admin">Admin?</label>
|
<div>
|
||||||
<input type="checkbox" name="is_admin" />
|
<input type="checkbox" name="is_admin" /><label for="admin">Admin?</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="second_factor">Requiere 2FA?</label>
|
<div>
|
||||||
<input type="checkbox" name="second_factor" />
|
<input type="checkbox" name="second_factor" /><label for="admin">Requiere 2FA?</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button id="create-user-submit" type="submit">Crear</button>
|
<button id="create-user-submit" type="submit">Crear</button>
|
||||||
</form>
|
</form>
|
||||||
@ -137,6 +204,14 @@
|
|||||||
<section id="registro" class="hidden">
|
<section id="registro" class="hidden">
|
||||||
<h2>Entradas recientes</h2>
|
<h2>Entradas recientes</h2>
|
||||||
<table>
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col span="1" style="width: 10%;">
|
||||||
|
<col span="1" style="width: 15%;">
|
||||||
|
<col span="1" style="width: 5%;">
|
||||||
|
<col span="1" style="width: 5%;">
|
||||||
|
<col span="1" style="width: 15%;">
|
||||||
|
<col span="1" style="width: 50%;">
|
||||||
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ts</th>
|
<th>ts</th>
|
||||||
|
@ -13,6 +13,7 @@ import (
|
|||||||
"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"
|
||||||
|
"git.rob.mx/nidito/puerta/internal/user"
|
||||||
"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/sirupsen/logrus"
|
||||||
@ -33,8 +34,10 @@ var adminTemplate []byte
|
|||||||
var staticFiles embed.FS
|
var staticFiles embed.FS
|
||||||
|
|
||||||
type HTTPConfig struct {
|
type HTTPConfig struct {
|
||||||
Listen int `yaml:"listen"`
|
// Listen is a hostname:port
|
||||||
Domain string `yaml:"domain"`
|
Listen string `yaml:"listen"`
|
||||||
|
// Origin describes the http origins to allow
|
||||||
|
Origin string `yaml:"domain"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@ -49,8 +52,8 @@ func ConfigDefaults(dbPath string) *Config {
|
|||||||
return &Config{
|
return &Config{
|
||||||
DB: dbPath,
|
DB: dbPath,
|
||||||
HTTP: &HTTPConfig{
|
HTTP: &HTTPConfig{
|
||||||
Listen: 8000,
|
Listen: "localhost:8000",
|
||||||
Domain: "localhost",
|
Origin: "http://localhost:8000",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -61,20 +64,19 @@ type auditLog struct {
|
|||||||
SecondFactor bool `db:"second_factor" json:"second_factor"`
|
SecondFactor bool `db:"second_factor" json:"second_factor"`
|
||||||
Failure string `db:"failure" json:"failure"`
|
Failure string `db:"failure" json:"failure"`
|
||||||
Err string `db:"error" json:"error"`
|
Err string `db:"error" json:"error"`
|
||||||
Success bool `db:"success" json:"success"`
|
|
||||||
IpAddress string `db:"ip_address" json:"ip_address"`
|
IpAddress string `db:"ip_address" json:"ip_address"`
|
||||||
UserAgent string `db:"user_agent" json:"user_agent"`
|
UserAgent string `db:"user_agent" json:"user_agent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAuditLog(r *http.Request, err error) *auditLog {
|
func newAuditLog(r *http.Request, err error) *auditLog {
|
||||||
user := auth.UserFromContext(r)
|
u := user.FromContext(r)
|
||||||
ip := r.RemoteAddr
|
ip := r.RemoteAddr
|
||||||
ua := r.Header.Get("user-agent")
|
ua := r.Header.Get("user-agent")
|
||||||
|
|
||||||
al := &auditLog{
|
al := &auditLog{
|
||||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
User: user.Handle,
|
User: u.Handle,
|
||||||
SecondFactor: user.Require2FA,
|
SecondFactor: u.Require2FA,
|
||||||
IpAddress: ip,
|
IpAddress: ip,
|
||||||
UserAgent: ua,
|
UserAgent: ua,
|
||||||
}
|
}
|
||||||
@ -129,7 +131,7 @@ 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) {
|
||||||
var err error
|
var err error
|
||||||
user := r.Context().Value(auth.ContextUser).(*auth.User)
|
u := user.FromContext(r)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
_, sqlErr := _db.Collection("log").Insert(newAuditLog(r, err))
|
_, sqlErr := _db.Collection("log").Insert(newAuditLog(r, err))
|
||||||
@ -138,14 +140,14 @@ func rex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err = user.IsAllowed(time.Now())
|
err = u.IsAllowed(time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorf("Denying rex to %s: %s", user.Name, err)
|
logrus.Errorf("Denying rex to %s: %s", u.Name, err)
|
||||||
http.Error(w, "Access denied", http.StatusForbidden)
|
http.Error(w, "Access denied", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = door.RequestToEnter(user.Name)
|
err = door.RequestToEnter(u.Name)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
message, code := errors.ToHTTP(err)
|
message, code := errors.ToHTTP(err)
|
||||||
@ -179,19 +181,17 @@ func Initialize(config *Config) (http.Handler, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
uri := fmt.Sprintf("http://%s:%d", config.HTTP.Domain, config.HTTP.Listen)
|
|
||||||
|
|
||||||
wan, err := webauthn.New(&webauthn.Config{
|
wan, err := webauthn.New(&webauthn.Config{
|
||||||
RPDisplayName: config.Name,
|
RPDisplayName: config.Name,
|
||||||
RPID: config.HTTP.Domain,
|
RPID: config.HTTP.Origin,
|
||||||
RPOrigins: []string{uri, fmt.Sprintf("http://%s:%d", config.HTTP.Domain, 8080)},
|
RPOrigins: []string{config.HTTP.Listen},
|
||||||
// RPIcon: "https://go-webauthn.local/logo.png",
|
// RPIcon: "https://go-webauthn.local/logo.png",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
am := auth.NewManager(wan, _db)
|
auth.Initialize(wan, _db)
|
||||||
|
|
||||||
serverRoot, err := fs.Sub(staticFiles, "static")
|
serverRoot, err := fs.Sub(staticFiles, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -200,19 +200,23 @@ 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.RequireAuthOrRedirect(renderTemplate(indexTemplate), "/login"))
|
router.GET("/", auth.RequireAuthOrRedirect(renderTemplate(indexTemplate), "/login"))
|
||||||
router.POST("/api/login", am.NewSession)
|
router.GET("/admin", auth.RequireAdmin(renderTemplate(adminTemplate)))
|
||||||
router.POST("/api/webauthn/register", am.RequireAuth(am.RegisterSecondFactor()))
|
|
||||||
router.POST("/api/rex", allowCORS(am.Enforce2FA(rex)))
|
|
||||||
router.GET("/admin", am.RequireAdmin(renderTemplate(adminTemplate)))
|
|
||||||
router.GET("/api/log", allowCORS(am.RequireAdmin(rexRecords)))
|
|
||||||
router.GET("/api/user", allowCORS(am.RequireAdmin(listUsers)))
|
|
||||||
router.GET("/api/user/:id", allowCORS(am.RequireAdmin(getUser)))
|
|
||||||
router.POST("/api/user", allowCORS(am.RequireAdmin(am.Enforce2FA(createUser))))
|
|
||||||
router.POST("/api/user/:id", allowCORS(am.RequireAdmin(am.Enforce2FA(updateUser))))
|
|
||||||
router.DELETE("/api/user/:id", allowCORS(am.RequireAdmin(am.Enforce2FA(deleteUser))))
|
|
||||||
|
|
||||||
return am.Route(router), nil
|
// regular api
|
||||||
|
router.POST("/api/login", auth.LoginHandler)
|
||||||
|
router.POST("/api/webauthn/register", auth.RequireAuth(auth.RegisterSecondFactor()))
|
||||||
|
router.POST("/api/rex", allowCORS(auth.Enforce2FA(rex)))
|
||||||
|
|
||||||
|
// admin api
|
||||||
|
router.GET("/api/log", allowCORS(auth.RequireAdmin(rexRecords)))
|
||||||
|
router.GET("/api/user", allowCORS(auth.RequireAdmin(listUsers)))
|
||||||
|
router.GET("/api/user/:id", allowCORS(auth.RequireAdmin(getUser)))
|
||||||
|
router.POST("/api/user", allowCORS(auth.RequireAdmin(auth.Enforce2FA(createUser))))
|
||||||
|
router.POST("/api/user/:id", allowCORS(auth.RequireAdmin(auth.Enforce2FA(updateUser))))
|
||||||
|
router.DELETE("/api/user/:id", allowCORS(auth.RequireAdmin(auth.Enforce2FA(deleteUser))))
|
||||||
|
|
||||||
|
return auth.Route(router), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTemplate(template []byte) httprouter.Handle {
|
func renderTemplate(template []byte) httprouter.Handle {
|
||||||
|
@ -1,28 +1,71 @@
|
|||||||
import * as webauthn from "./webauthn.js"
|
import * as webauthn from "./webauthn.js"
|
||||||
|
|
||||||
// const host = document.location.protocol + "//" + document.location.host
|
const host = document.location.protocol + "//" + document.location.host
|
||||||
const host = "http://localhost:8081"
|
// const host = "http://localhost:8081"
|
||||||
|
|
||||||
|
function localDate(src) {
|
||||||
|
const exp = new Date(src)
|
||||||
|
return new Date(exp - exp.getTimezoneOffset() * 60000).toISOString().replace("Z", "").replace(/\.\d+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
class UserInfoPanel extends HTMLElement {
|
class UserInfoPanel extends HTMLElement {
|
||||||
constructor(user) {
|
constructor(user) {
|
||||||
super()
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
let template = document.getElementById("user-info-panel")
|
let template = document.getElementById("user-info-panel")
|
||||||
const shadowRoot = this.attachShadow({ mode: "open" })
|
const shadowRoot = this.attachShadow({ mode: "open" })
|
||||||
const panel = template.content.cloneNode(true)
|
const panel = template.content.cloneNode(true)
|
||||||
|
|
||||||
let handle = user.handle
|
let handle = this.getAttribute("handle")
|
||||||
panel.querySelector('h3').innerHTML = user.name
|
panel.querySelector('h3').innerHTML = this.getAttribute("name")
|
||||||
panel.querySelector('input[name=name]').value = user.name
|
panel.querySelector('input[name=name]').value = this.getAttribute("name")
|
||||||
|
|
||||||
panel.querySelector('form').action = panel.querySelector('form').action.replace(":id", handle)
|
const form = panel.querySelector('form')
|
||||||
panel.querySelector('pre').textContent = handle
|
form.action = panel.querySelector('form').action.replace(":id", handle)
|
||||||
|
panel.querySelector('code').textContent = handle
|
||||||
|
|
||||||
panel.querySelector('input[name=greeting]').value = user.greeting
|
panel.querySelector('input[name=greeting]').value = this.getAttribute("greeting")
|
||||||
panel.querySelector('input[name=schedule]').value = user.schedule
|
if (this.hasAttribute('schedule')){
|
||||||
panel.querySelector('input[name=expires]').value = user.expires
|
panel.querySelector('input[name=schedule]').value = this.getAttribute("schedule")
|
||||||
panel.querySelector('input[name=max_ttl]').value = user.max_ttl
|
}
|
||||||
panel.querySelector('input[name=is_admin]').checked = user.is_admin
|
if (this.hasAttribute('expires')){
|
||||||
panel.querySelector('input[name=second_factor]').checked = user.second_factor
|
panel.querySelector('input[name=expires]').value = localDate(this.getAttribute("expires"))
|
||||||
|
}
|
||||||
|
if (this.hasAttribute("is_admin")) {
|
||||||
|
const adminSpan = document.createElement("span")
|
||||||
|
adminSpan.innerText = "🔑"
|
||||||
|
panel.querySelector(".user-info-meta").prepend(adminSpan)
|
||||||
|
}
|
||||||
|
panel.querySelector('input[name=max_ttl]').value = this.getAttribute("max_ttl")
|
||||||
|
panel.querySelector('input[name=is_admin]').checked = this.hasAttribute("is_admin")
|
||||||
|
panel.querySelector('input[name=second_factor]').checked = this.hasAttribute("second_factor")
|
||||||
|
panel.querySelector("button.user-edit").addEventListener('click', evt => {
|
||||||
|
form.classList.toggle("hidden")
|
||||||
|
this.classList.toggle("editing")
|
||||||
|
})
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (evt) => {
|
||||||
|
evt.preventDefault()
|
||||||
|
await UpdateUser(form)
|
||||||
|
})
|
||||||
|
|
||||||
|
panel.querySelector("button.user-delete").addEventListener('click', async evt => {
|
||||||
|
evt.preventDefault()
|
||||||
|
if (confirm(`Seguro que borramos a ${handle}?`)) {
|
||||||
|
let response = await webauthn.withAuth(`${host}/api/user/${handle}`, {
|
||||||
|
credentials: "include",
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Could not delete user:", response)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
})
|
||||||
shadowRoot.appendChild(panel)
|
shadowRoot.appendChild(panel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -35,7 +78,7 @@ class REXRow extends HTMLElement {
|
|||||||
const shadowRoot = this.attachShadow({ mode: "open" })
|
const shadowRoot = this.attachShadow({ mode: "open" })
|
||||||
const row = template.content.cloneNode(true)
|
const row = template.content.cloneNode(true)
|
||||||
|
|
||||||
row.querySelector('.log-record-timestamp').innerText = (new Date(rex.timestamp)).toISOString()
|
row.querySelector('.log-record-timestamp').innerText = localDate(rex.timestamp)
|
||||||
row.querySelector('.log-record-user').innerText = rex.user
|
row.querySelector('.log-record-user').innerText = rex.user
|
||||||
row.querySelector('.log-record-status').innerHTML = !rex.error ? "ok" : `<strong>${rex.error}</strong> ${rex.failure}`
|
row.querySelector('.log-record-status').innerHTML = !rex.error ? "ok" : `<strong>${rex.error}</strong> ${rex.failure}`
|
||||||
row.querySelector('.log-record-second_factor').innerText = rex.second_factor ? "✓" : ""
|
row.querySelector('.log-record-second_factor').innerText = rex.second_factor ? "✓" : ""
|
||||||
@ -45,7 +88,7 @@ class REXRow extends HTMLElement {
|
|||||||
shadowRoot.appendChild(row)
|
shadowRoot.appendChild(row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
customElements.define("rex-record", REXRow)
|
customElements.define("rex-record", REXRow, {extends: "tr"})
|
||||||
|
|
||||||
async function fetchUsers() {
|
async function fetchUsers() {
|
||||||
console.debug("fetching users")
|
console.debug("fetching users")
|
||||||
@ -64,7 +107,16 @@ async function fetchUsers() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector("#user-list").replaceChildren(...json.map(u => new UserInfoPanel(u)))
|
document.querySelector("#user-list").replaceChildren(...json.map(u => {
|
||||||
|
const ul = new UserInfoPanel()
|
||||||
|
Object.keys(u).forEach(k => {
|
||||||
|
let val = u[k]
|
||||||
|
if (!val) { return }
|
||||||
|
if(typeof(val) == "boolean") { val = k; }
|
||||||
|
ul.setAttribute(k, u[k])
|
||||||
|
})
|
||||||
|
return ul
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLog() {
|
async function fetchLog() {
|
||||||
@ -87,21 +139,24 @@ async function fetchLog() {
|
|||||||
document.querySelector("#rex-records").replaceChildren(...json.map(rex => {
|
document.querySelector("#rex-records").replaceChildren(...json.map(rex => {
|
||||||
const tr = document.createElement("tr")
|
const tr = document.createElement("tr")
|
||||||
tr.classList.add("rex-staus-" + (!rex.error ? "ok" : "failure"))
|
tr.classList.add("rex-staus-" + (!rex.error ? "ok" : "failure"))
|
||||||
|
tr.classList.add("rex-record")
|
||||||
|
|
||||||
const status = !rex.error ? "ok" : `<strong>${rex.error}</strong> ${rex.failure}`
|
const status = !rex.error ? "ok" : `<strong>${rex.error}</strong> ${rex.failure}`
|
||||||
tr.innerHTML = `<th class="log-record-timestamp">${(new Date(rex.timestamp)).toISOString()}</th>
|
tr.innerHTML = `<th class="log-record-timestamp">${localDate(rex.timestamp)}</th>
|
||||||
<td class="log-record-user">${rex.user}</td>
|
<td class="log-record-user">${rex.user}</td>
|
||||||
<td class="log-record-status">${status}</td>
|
<td class="log-record-status">${status}</td>
|
||||||
<td class="log-record-second_factor">${rex.second_factor ? "✓" : ""}</td>
|
<td class="log-record-second_factor">${rex.second_factor ? "✓" : ""}</td>
|
||||||
<td class="log-record-ip_address">${rex.ip_address}</td>
|
<td class="log-record-ip_address">${rex.ip_address}</td>
|
||||||
<td class="log-record-user_agent">${rex.user_agent}</td>`
|
<td class="log-record-user_agent">${rex.user_agent}</td>`
|
||||||
|
// table rows and shadow dom don't really play along
|
||||||
|
// also, the `is` attribute is not supported by safari :/
|
||||||
// tr.appendChild(new REXRow(record))
|
// tr.appendChild(new REXRow(record))
|
||||||
return tr
|
return tr
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function CreateUser(form) {
|
function userFromForm(form) {
|
||||||
let user = Object.fromEntries(new FormData(form))
|
const user = Object.fromEntries(new FormData(form))
|
||||||
delete(user.id)
|
delete(user.id)
|
||||||
if (user.expires != "") {
|
if (user.expires != "") {
|
||||||
user.expires = (new Date(user.expires)).toISOString()
|
user.expires = (new Date(user.expires)).toISOString()
|
||||||
@ -117,9 +172,32 @@ async function CreateUser(form) {
|
|||||||
delete(user.schedule)
|
delete(user.schedule)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user.is_admin = user.is_admin == "on"
|
||||||
|
user.second_factor = user.second_factor == "on"
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
user.admin = (user.admin == "on")
|
async function UpdateUser(form) {
|
||||||
user.second_factor = (user.second_factor == "on")
|
const user = userFromForm(form)
|
||||||
|
|
||||||
|
let response = await webauthn.withAuth(host + form.getAttribute("action"), {
|
||||||
|
credentials: "include",
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(user),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Could not update user:", response)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function CreateUser(form) {
|
||||||
|
const user = userFromForm(form)
|
||||||
|
|
||||||
let response = await webauthn.withAuth(host + form.getAttribute("action"), {
|
let response = await webauthn.withAuth(host + form.getAttribute("action"), {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@ -133,12 +211,11 @@ async function CreateUser(form) {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Could not create user:", response)
|
throw new Error("Could not create user:", response)
|
||||||
}
|
}
|
||||||
|
form.reset()
|
||||||
window.location.reload()
|
window.location.hash = "#invitades"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function switchTab() {
|
async function switchTab() {
|
||||||
let tabName = window.location.hash.toLowerCase().replace("#", "")
|
let tabName = window.location.hash.toLowerCase().replace("#", "")
|
||||||
let activate = async () => true
|
let activate = async () => true
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
/* SPDX-License-Identifier: Apache-2.0
|
||||||
|
Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx> */
|
||||||
button {
|
button {
|
||||||
background: rgba(255,255,255,.6);
|
background: rgba(255,255,255,.6);
|
||||||
font-family: "Aestetico", sans-serif;
|
font-family: "Aestetico", sans-serif;
|
||||||
@ -65,6 +67,13 @@ input {
|
|||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 50vw;
|
max-width: 50vw;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=checkbox] {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: .8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||||
const button = document.querySelector("#open button")
|
const button = document.querySelector("#open button")
|
||||||
const form = document.querySelector("#open")
|
const form = document.querySelector("#open")
|
||||||
import * as webauthn from "./webauthn.js"
|
import * as webauthn from "./webauthn.js"
|
||||||
|
|
||||||
// const host = document.location.protocol + "//" + document.location.host
|
const host = document.location.protocol + "//" + document.location.host
|
||||||
const host = "http://localhost:8081"
|
// const host = "http://localhost:8081"
|
||||||
|
|
||||||
async function RequestToEnter() {
|
async function RequestToEnter() {
|
||||||
console.debug("requesting to enter")
|
console.debug("requesting to enter")
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||||
const button = document.querySelector("#auth")
|
const button = document.querySelector("#auth")
|
||||||
const form = document.querySelector("#login")
|
const form = document.querySelector("#login")
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||||
importScripts(
|
importScripts(
|
||||||
'https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js'
|
'https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js'
|
||||||
);
|
);
|
||||||
@ -8,7 +10,7 @@ workbox.loadModule('workbox-strategies');
|
|||||||
self.addEventListener("install", event => {
|
self.addEventListener("install", event => {
|
||||||
console.log("Service worker installed");
|
console.log("Service worker installed");
|
||||||
|
|
||||||
const urlsToCache = ["/login", "/", "index.css", "/index.js", "/login.js"];
|
const urlsToCache = ["/login", "/", "index.css", "/index.js", "/login.js", "/webauthn.js"];
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open("pwa-assets")
|
caches.open("pwa-assets")
|
||||||
.then(cache => {
|
.then(cache => {
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||||
const { create: createCredentials, get: getCredentials } = hankoWebAuthn;
|
const { create: createCredentials, get: getCredentials } = hankoWebAuthn;
|
||||||
|
|
||||||
const charsToEncode = /[\u007f-\uffff]/g;
|
const charsToEncode = /[\u007f-\uffff]/g;
|
||||||
@ -56,6 +58,7 @@ async function register(challenge) {
|
|||||||
console.debug(`webauthn: registering credentials with server: ${JSON.stringify(credential)}`)
|
console.debug(`webauthn: registering credentials with server: ${JSON.stringify(credential)}`)
|
||||||
let response = await window.fetch("/api/webauthn/register", {
|
let response = await window.fetch("/api/webauthn/register", {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
method: "POST",
|
||||||
body: JSON.stringify(credential),
|
body: JSON.stringify(credential),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-type': 'application/json'
|
'Content-type': 'application/json'
|
||||||
|
25
internal/user/credential.go
Normal file
25
internal/user/credential.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Credential struct {
|
||||||
|
UserID int `db:"user"`
|
||||||
|
Data string `db:"data"`
|
||||||
|
wan *webauthn.Credential
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credential) AsWebAuthn() webauthn.Credential {
|
||||||
|
if c.wan == nil {
|
||||||
|
c.wan = &webauthn.Credential{}
|
||||||
|
if err := json.Unmarshal([]byte(c.Data), &c.wan); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return *c.wan
|
||||||
|
}
|
@ -1,8 +1,9 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||||
package auth
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -32,24 +33,37 @@ func parseHour(src string) (float64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return 0.0, fmt.Errorf("unknown format for hour: %s", hm)
|
return 0.0, fmt.Errorf("unknown format for hour: %s", hm)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSchedule struct {
|
type Schedule struct {
|
||||||
src string
|
src string
|
||||||
days []int
|
days []int
|
||||||
hours []float64
|
hours []float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d UserSchedule) MarshalDB() (any, error) {
|
func (d Schedule) MarshalDB() (any, error) {
|
||||||
return json.Marshal(d.src)
|
return json.Marshal(d.src)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d UserSchedule) MarshalJSON() ([]byte, error) {
|
func (d Schedule) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(d.src)
|
return json.Marshal(d.src)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *UserSchedule) Parse() error {
|
func (d *Schedule) UnmarshalJSON(value []byte) error {
|
||||||
|
var str string
|
||||||
|
if err := json.Unmarshal(value, &str); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed := Schedule{src: str}
|
||||||
|
if err := parsed.Parse(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*d = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Schedule) Parse() error {
|
||||||
for _, kv := range strings.Split(d.src, " ") {
|
for _, kv := range strings.Split(d.src, " ") {
|
||||||
kvSlice := strings.Split(kv, "=")
|
kvSlice := strings.Split(kv, "=")
|
||||||
key := kvSlice[0]
|
key := kvSlice[0]
|
||||||
@ -64,7 +78,7 @@ func (d *UserSchedule) Parse() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logrus.Infof("Parsed schedule days from: %d until %d", from, until)
|
logrus.Debugf("Parsed schedule days from: %d until %d", from, until)
|
||||||
d.days = []int{from, until}
|
d.days = []int{from, until}
|
||||||
case "hours":
|
case "hours":
|
||||||
from, err := parseHour(values[0])
|
from, err := parseHour(values[0])
|
||||||
@ -75,39 +89,37 @@ func (d *UserSchedule) Parse() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logrus.Infof("Parsed schedule hours from: %f until %f", from, until)
|
logrus.Debugf("Parsed schedule hours from: %f until %f", from, until)
|
||||||
d.hours = []float64{from, until}
|
d.hours = []float64{from, until}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *UserSchedule) UnmarshalDB(value any) error {
|
func (d *Schedule) Scan(value any) error {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var src string
|
var src string
|
||||||
if err := json.Unmarshal(value.([]byte), &src); err != nil {
|
var ok bool
|
||||||
|
if src, ok = value.(string); !ok {
|
||||||
|
if err := json.Unmarshal(value.([]byte), &src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.src = src
|
||||||
|
|
||||||
|
// parsed := UserSchedule{src: src}
|
||||||
|
if err := d.Parse(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed := UserSchedule{src: src}
|
|
||||||
if err := parsed.Parse(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
*d = parsed
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *UserSchedule) UnmarshalJSON(value []byte) error {
|
func (sch *Schedule) AllowedAt(t time.Time) bool {
|
||||||
parsed := UserSchedule{src: string(value)}
|
|
||||||
if err := parsed.Parse(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
*d = parsed
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sch *UserSchedule) AllowedAt(t time.Time) bool {
|
|
||||||
weekDay := int(t.Weekday())
|
weekDay := int(t.Weekday())
|
||||||
h, m, s := t.Clock()
|
h, m, s := t.Clock()
|
||||||
fractionalHour := float64(h) + (float64(m*60.0+s) / 3600.0)
|
fractionalHour := float64(h) + (float64(m*60.0+s) / 3600.0)
|
||||||
@ -128,7 +140,7 @@ func (sch *UserSchedule) AllowedAt(t time.Time) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = (db.Unmarshaler(&UserSchedule{}))
|
var _ sql.Scanner = &Schedule{}
|
||||||
var _ = (db.Marshaler(&UserSchedule{}))
|
var _ db.Marshaler = &Schedule{}
|
||||||
var _ = (json.Marshaler(&UserSchedule{}))
|
var _ json.Marshaler = &Schedule{}
|
||||||
var _ = (json.Unmarshaler(&UserSchedule{}))
|
var _ json.Unmarshaler = &Schedule{}
|
108
internal/user/ttl.go
Normal file
108
internal/user/ttl.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/upper/db/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DefaultTTL = TTL{src: "30d", duration: time.Hour * 24 * 30}
|
||||||
|
|
||||||
|
type TTL struct {
|
||||||
|
src string
|
||||||
|
duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ttl *TTL) Parse() (err error) {
|
||||||
|
if ttl.src == "" {
|
||||||
|
return fmt.Errorf("could not parse empty ttl")
|
||||||
|
}
|
||||||
|
suffix := ttl.src[len(ttl.src)-1]
|
||||||
|
|
||||||
|
toParse := ttl.src
|
||||||
|
if suffix == 'd' || suffix == 'w' || suffix == 'M' {
|
||||||
|
multiplier := 1
|
||||||
|
switch suffix {
|
||||||
|
case 'd':
|
||||||
|
multiplier = 24
|
||||||
|
case 'w':
|
||||||
|
multiplier = 24 * 7
|
||||||
|
case 'M':
|
||||||
|
multiplier = 24 * 7 * 30
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unknown suffix for time duration %s", string(suffix))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toParse = toParse[0 : len(toParse)-1]
|
||||||
|
var days int
|
||||||
|
days, err = strconv.Atoi(toParse)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toParse = fmt.Sprintf("%dh", days*multiplier)
|
||||||
|
}
|
||||||
|
ttl.duration, err = time.ParseDuration(toParse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ttl *TTL) Scan(value any) error {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var src string
|
||||||
|
var ok bool
|
||||||
|
if src, ok = value.(string); !ok {
|
||||||
|
if err := json.Unmarshal(value.([]byte), &src); err != nil {
|
||||||
|
return fmt.Errorf("could not decode ttl as json %s: %s", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl.src = src
|
||||||
|
|
||||||
|
if err := ttl.Parse(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ttl *TTL) UnmarshalJSON(value []byte) error {
|
||||||
|
if err := json.Unmarshal(value, &ttl.src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ttl.Parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ttl *TTL) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(ttl.src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ttl *TTL) MarshalDB() (any, error) {
|
||||||
|
return json.Marshal(ttl.src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ttl *TTL) FromNow() time.Time {
|
||||||
|
return time.Now().Add(ttl.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ttl *TTL) Seconds() int {
|
||||||
|
return int(ttl.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ sql.Scanner = &TTL{}
|
||||||
|
var _ db.Marshaler = &TTL{}
|
||||||
|
var _ json.Marshaler = &TTL{}
|
||||||
|
var _ json.Unmarshaler = &TTL{}
|
@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||||
package auth
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@ -8,30 +8,16 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.rob.mx/nidito/puerta/internal/constants"
|
||||||
|
"git.rob.mx/nidito/puerta/internal/errors"
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/upper/db/v4"
|
"github.com/upper/db/v4"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Credential struct {
|
func FromContext(req *http.Request) *User {
|
||||||
UserID int `db:"user"`
|
u := req.Context().Value(constants.ContextUser)
|
||||||
Data string `db:"data"`
|
|
||||||
wan *webauthn.Credential
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Credential) AsWebAuthn() webauthn.Credential {
|
|
||||||
if c.wan == nil {
|
|
||||||
c.wan = &webauthn.Credential{}
|
|
||||||
if err := json.Unmarshal([]byte(c.Data), &c.wan); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return *c.wan
|
|
||||||
}
|
|
||||||
|
|
||||||
func UserFromContext(req *http.Request) *User {
|
|
||||||
u := req.Context().Value(ContextUser)
|
|
||||||
|
|
||||||
if u != nil {
|
if u != nil {
|
||||||
return u.(*User)
|
return u.(*User)
|
||||||
@ -40,16 +26,16 @@ func UserFromContext(req *http.Request) *User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int `db:"id,omitempty" json:"-"`
|
ID int `db:"id,omitempty" json:"-"`
|
||||||
Expires *time.Time `db:"expires,omitempty" json:"expires"`
|
Expires *UTCTime `db:"expires,omitempty" json:"expires,omitempty"`
|
||||||
Greeting string `db:"greeting" json:"greeting"`
|
Greeting string `db:"greeting" json:"greeting"`
|
||||||
Handle string `db:"handle" json:"handle"`
|
Handle string `db:"handle" json:"handle"`
|
||||||
IsAdmin bool `db:"is_admin" json:"is_admin"`
|
IsAdmin bool `db:"is_admin" json:"is_admin"`
|
||||||
Name string `db:"name" json:"name"`
|
Name string `db:"name" json:"name"`
|
||||||
Password string `db:"password" json:"password"`
|
Password string `db:"password" json:"password"`
|
||||||
Require2FA bool `db:"second_factor" json:"second_factor"`
|
Require2FA bool `db:"second_factor" json:"second_factor"`
|
||||||
Schedule *UserSchedule `db:"schedule,omitempty" json:"schedule,omitempty"`
|
Schedule *Schedule `db:"schedule,omitempty" json:"schedule,omitempty"`
|
||||||
TTL *TTL `db:"max_ttl,omitempty" json:"max_ttl,omitempty"`
|
TTL *TTL `db:"max_ttl,omitempty" json:"max_ttl,omitempty"`
|
||||||
credentials []*Credential
|
credentials []*Credential
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,17 +123,21 @@ func (user *User) IsAllowed(t time.Time) error {
|
|||||||
func (user *User) Login(password string) error {
|
func (user *User) Login(password string) error {
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||||
reason := fmt.Sprintf("Incorrect password for %s", user.Name)
|
reason := fmt.Sprintf("Incorrect password for %s", user.Name)
|
||||||
return &InvalidCredentials{code: http.StatusForbidden, reason: reason}
|
return &errors.InvalidCredentials{Status: http.StatusForbidden, Reason: reason}
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Expired() {
|
if user.Expired() {
|
||||||
reason := fmt.Sprintf("Expired user tried to login: %s", user.Name)
|
reason := fmt.Sprintf("Expired user tried to login: %s", user.Name)
|
||||||
return &InvalidCredentials{code: http.StatusForbidden, reason: reason}
|
return &errors.InvalidCredentials{Status: http.StatusForbidden, Reason: reason}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (user *User) HasCredentials() bool {
|
||||||
|
return len(user.credentials) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// implement interfaces
|
// implement interfaces
|
||||||
var _ = db.Record(&User{})
|
var _ db.Record = &User{}
|
||||||
var _ = webauthn.User(&User{})
|
var _ webauthn.User = &User{}
|
73
internal/user/utctime.go
Normal file
73
internal/user/utctime.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/upper/db/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UTCTime struct {
|
||||||
|
src string
|
||||||
|
time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ sql.Scanner = &UTCTime{}
|
||||||
|
var _ db.Marshaler = &UTCTime{}
|
||||||
|
var _ json.Marshaler = &UTCTime{}
|
||||||
|
var _ json.Unmarshaler = &UTCTime{}
|
||||||
|
|
||||||
|
func (t *UTCTime) Parse() (err error) {
|
||||||
|
if t.src == "" {
|
||||||
|
return fmt.Errorf("could not parse empty ttl")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.time, err = time.Parse(time.RFC3339, t.src)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *UTCTime) Scan(value any) error {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
if t.src, ok = value.(string); !ok {
|
||||||
|
if err := json.Unmarshal(value.([]byte), &t.src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.src == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.Parse(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *UTCTime) Before(other time.Time) bool {
|
||||||
|
return t.time.Before(other)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *UTCTime) UnmarshalJSON(value []byte) error {
|
||||||
|
if err := json.Unmarshal(value, &t.src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return t.Parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *UTCTime) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(t.time.UTC().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *UTCTime) MarshalDB() (any, error) {
|
||||||
|
return t.MarshalJSON()
|
||||||
|
}
|
@ -21,7 +21,7 @@ CREATE TABLE credential(
|
|||||||
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(user);
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE session(
|
CREATE TABLE session(
|
||||||
|
Loading…
Reference in New Issue
Block a user