refactor and nice stuff

This commit is contained in:
Roberto Hidalgo 2023-01-03 22:21:49 -06:00
parent 63b9a753e8
commit 7e5dbed4f5
27 changed files with 798 additions and 429 deletions

View File

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

View File

@ -5,12 +5,11 @@ package admin
import (
"fmt"
"os"
"time"
"git.rob.mx/nidito/chinampa"
"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/user"
"github.com/sirupsen/logrus"
"github.com/upper/db/v4/adapter/sqlite"
"golang.org/x/crypto/bcrypt"
@ -44,12 +43,14 @@ var userAddCommand = &command.Command{
},
Options: command.Options{
"config": {
Type: "string",
Default: "./config.joao.yaml",
Type: "string",
Default: "./config.joao.yaml",
Description: "the config to read from",
},
"db": {
Type: "string",
Default: "./puerta.db",
Type: "string",
Default: "./puerta.db",
Description: "the database to operate on",
},
"ttl": {
Type: "string",
@ -102,45 +103,50 @@ var userAddCommand = &command.Command{
// Options: {},
})
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)
if err != nil {
return err
return fmt.Errorf("could not hash password: %s", err)
}
user := &auth.User{
Name: cmd.Arguments[0].ToString(),
u := &user.User{
Name: cmd.Arguments[1].ToString(),
Password: string(password),
Handle: cmd.Arguments[1].ToString(),
Handle: cmd.Arguments[0].ToString(),
Greeting: greeting,
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 != "" {
user.Schedule = &auth.UserSchedule{}
if err := user.Schedule.UnmarshalDB([]byte(schedule)); err != nil {
return err
u.Schedule = &user.Schedule{}
if err := u.Schedule.Scan(schedule); err != nil {
return fmt.Errorf("could not decode schedule %s: %s", schedule, err)
}
}
if expires != "" {
t, err := time.Parse(time.RFC3339, expires)
if err != nil {
return err
t := &user.UTCTime{}
if err := t.Scan(expires); err != nil {
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 {
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
},

View File

@ -56,6 +56,6 @@ var serverCommand = &command.Command{
}
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)
},
}

View File

@ -1,13 +1,14 @@
name: Casa de Alguien
name: Casa de alguien
adapter:
kind: dry-run
ip: 192.168.1.256
username: nobody
device: -1
# but really
# kind: hue
# 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:
listen: 8000
domain: localhost
db: ./puerta.db
listen: "localhost:8080"
origin: http://localhost:8080

View File

@ -7,6 +7,9 @@ import (
"net/http"
"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/go-webauthn/webauthn/webauthn"
"github.com/julienschmidt/httprouter"
@ -14,38 +17,26 @@ import (
"github.com/upper/db/v4"
)
type AuthContext string
var _db db.Session
var _wan *webauthn.WebAuthn
var _sess *scs.SessionManager
const (
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 {
func Initialize(wan *webauthn.WebAuthn, db db.Session) {
sessionManager := scs.New()
sessionManager.Lifetime = 5 * time.Minute
return &Manager{
db: db,
wan: wan,
sess: sessionManager,
}
_db = db
_wan = wan
}
func (am *Manager) Route(router http.Handler) http.Handler {
return am.sess.LoadAndSave(router)
func Route(router http.Handler) http.Handler {
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)
}
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()
if err != nil {
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")
password := req.FormValue("password")
user := &User{}
if err := am.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)}
user := &user.User{}
if err := _db.Get(user, db.Cond{"name": username}); err != nil {
err := &errors.InvalidCredentials{Status: http.StatusForbidden, Reason: fmt.Sprintf("User not found for name: %s (%s)", username, err)}
err.Log()
http.Error(w, err.Error(), err.Code())
return
@ -66,7 +57,7 @@ func (am *Manager) NewSession(w http.ResponseWriter, req *http.Request, ps httpr
if err := user.Login(password); err != nil {
code := http.StatusBadRequest
status := http.StatusText(code)
if err, ok := err.(InvalidCredentials); ok {
if err, ok := err.(errors.InvalidCredentials); ok {
code = err.Code()
status = err.Error()
err.Log()
@ -75,13 +66,13 @@ func (am *Manager) NewSession(w http.ResponseWriter, req *http.Request, ps httpr
return
}
sess, err := NewSession(user, am.db.Collection("session"))
sess, err := NewSession(user, _db.Collection("session"))
if err != nil {
http.Error(w, fmt.Sprintf("Could not create a session: %s", err), http.StatusInternalServerError)
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)

View File

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

View File

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

View File

@ -7,59 +7,63 @@ import (
"fmt"
"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/sirupsen/logrus"
"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) {
ctxUser := req.Context().Value(ContextUser)
req = func() *http.Request {
if ctxUser != nil {
return req
}
u := user.FromContext(req)
if u != nil {
handler(w, req, ps)
return
}
cookie, err := req.Cookie(string(ContextCookieName))
req = func() *http.Request {
cookie, err := req.Cookie(string(constants.ContextCookieName))
if err != nil {
logrus.Debugf("no cookie for user found in jar <%s>", req.Cookies())
return req
}
session := &SessionUser{}
q := am.db.SQL().
Select("s.token as token, ", "u.*").
q := _db.SQL().
Select("s.token as token, s.expires as expires", "u.*").
From("session as s").
Join("user as u").On("s.user = u.id").
Where(db.Cond{"s.token": cookie.Value})
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=/;", constants.ContextCookieName, "", -1))
return req
}
if session.Expired() {
if session.Expired() || session.User.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()
w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Secure; Path=/;", constants.ContextCookieName, "", -1))
err := _db.Collection("session").Find(db.Cond{"token": cookie.Value}).Delete()
if err != nil {
logrus.Errorf("could not purge expired session from DB: %s", err)
}
return req
}
return req.WithContext(context.WithValue(req.Context(), ContextUser, &session.User))
return req.WithContext(context.WithValue(req.Context(), constants.ContextUser, &session.User))
}()
handler(w, req, ps)
}
}
func (am *Manager) RequireAuth(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)
func RequireAuth(handler httprouter.Handle) httprouter.Handle {
return withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
if req.Context().Value(constants.ContextUser) == nil {
requestAuth(w, http.StatusUnauthorized)
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 {
return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
if req.Context().Value(ContextUser) == nil {
func RequireAuthOrRedirect(handler httprouter.Handle, target string) httprouter.Handle {
return withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
if req.Context().Value(constants.ContextUser) == nil {
http.Redirect(w, req, target, http.StatusTemporaryRedirect)
return
}
@ -78,15 +82,15 @@ func (am *Manager) RequireAuthOrRedirect(handler httprouter.Handle, target strin
})
}
func (am *Manager) RegisterSecondFactor() httprouter.Handle {
return am.RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
user := req.Context().Value(ContextUser).(*User)
if !user.Require2FA {
func RegisterSecondFactor() httprouter.Handle {
return RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
u := user.FromContext(req)
if !u.Require2FA {
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
return
}
err := am.WebAuthnFinishRegistration(req)
err := webAuthnFinishRegistration(req)
if err != nil {
logrus.Errorf("Failed during webauthn flow: %s", err.Error())
w.WriteHeader(http.StatusInternalServerError)
@ -96,31 +100,30 @@ func (am *Manager) RegisterSecondFactor() httprouter.Handle {
})
}
func (am *Manager) Enforce2FA(handler httprouter.Handle) httprouter.Handle {
return am.RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
user := req.Context().Value(ContextUser).(*User)
if !user.Require2FA {
func Enforce2FA(handler httprouter.Handle) httprouter.Handle {
return RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
u := user.FromContext(req)
if !u.Require2FA {
handler(w, req, ps)
return
}
logrus.Debug("Enforcing 2fa for request")
var err error
err = user.FetchCredentials(am.db)
if err != nil {
if err := u.FetchCredentials(_db); err != nil {
logrus.Errorf("Failed fetching credentials: %s", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
if len(user.credentials) == 0 {
err = am.WebAuthnBeginRegistration(req)
var flow func(*http.Request) error
if !u.HasCredentials() {
flow = webAuthnBeginRegistration
} else {
err = am.WebAuthnLogin(req)
flow = webAuthnLogin
}
if err != nil {
if wafc, ok := err.(WebAuthFlowChallenge); ok {
if err := flow(req); err != nil {
if wafc, ok := err.(errors.WebAuthFlowChallenge); ok {
w.WriteHeader(200)
w.Header().Add("content-type", "application/json")
w.Header().Add("webauthn", wafc.Header())
@ -132,16 +135,20 @@ func (am *Manager) Enforce2FA(handler httprouter.Handle) httprouter.Handle {
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)
})
}
func (am *Manager) RequireAdmin(handler httprouter.Handle) httprouter.Handle {
return am.RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
user := req.Context().Value(ContextUser).(*User)
func RequireAdmin(handler httprouter.Handle) httprouter.Handle {
return RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
user := req.Context().Value(constants.ContextUser).(*user.User)
if !user.IsAdmin {
am.requestAuth(w, http.StatusUnauthorized)
requestAuth(w, http.StatusUnauthorized)
return
}
handler(w, req, ps)

View File

@ -7,6 +7,7 @@ import (
"strings"
"time"
"git.rob.mx/nidito/puerta/internal/user"
"github.com/upper/db/v4"
)
@ -48,22 +49,22 @@ type Session struct {
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 {
return sess.Collection("session")
}
func (s *Session) Expired() bool {
return s.Expires.Before(time.Now())
type SessionUser struct {
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{
Token: NewToken(),
UserID: user.ID,

View File

@ -10,6 +10,8 @@ import (
"net/http"
"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/webauthn"
"github.com/sirupsen/logrus"
@ -20,10 +22,10 @@ const SessionNameWANAuth = "wan-auth"
const SessionNameWANRegister = "wan-register"
const HeaderNameWAN = "webauthn"
func (am *Manager) WebAuthnBeginRegistration(req *http.Request) error {
user := UserFromContext(req)
func webAuthnBeginRegistration(req *http.Request) error {
user := user.FromContext(req)
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 {
err = fmt.Errorf("error starting webauthn: %s", err)
logrus.Error(err)
@ -35,13 +37,13 @@ func (am *Manager) WebAuthnBeginRegistration(req *http.Request) error {
return err
}
am.sess.Put(req.Context(), SessionNameWANRegister, b.Bytes())
return WebAuthFlowChallenge{"register", &options}
_sess.Put(req.Context(), SessionNameWANRegister, b.Bytes())
return errors.WebAuthFlowChallenge{Flow: "register", Data: &options}
}
func (am *Manager) WebAuthnFinishRegistration(req *http.Request) error {
user := UserFromContext(req)
sd := am.sess.PopBytes(req.Context(), SessionNameWANRegister)
func webAuthnFinishRegistration(req *http.Request) error {
u := user.FromContext(req)
sd := _sess.PopBytes(req.Context(), SessionNameWANRegister)
if sd == nil {
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
}
cred, err := am.wan.FinishRegistration(user, sessionData, req)
cred, err := _wan.FinishRegistration(u, sessionData, req)
if err != nil {
return fmt.Errorf("error finishing webauthn registration: %s", err)
}
@ -61,22 +63,22 @@ func (am *Manager) WebAuthnFinishRegistration(req *http.Request) error {
if err != nil {
return fmt.Errorf("error encoding webauthn credential for storage: %s", err)
}
credential := &Credential{
UserID: user.ID,
credential := &user.Credential{
UserID: u.ID,
Data: string(data),
}
_, err = am.db.Collection("credential").Insert(credential)
_, err = _db.Collection("credential").Insert(credential)
return err
}
func (am *Manager) WebAuthnLogin(req *http.Request) error {
user := UserFromContext(req)
sd := am.sess.PopBytes(req.Context(), SessionNameWANAuth)
func webAuthnLogin(req *http.Request) error {
user := user.FromContext(req)
sd := _sess.PopBytes(req.Context(), SessionNameWANAuth)
if sd == nil {
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 {
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)
}
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
@ -112,10 +114,10 @@ func (am *Manager) WebAuthnLogin(req *http.Request) error {
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
}
func (am *Manager) Cleanup() error {
return am.db.Collection("session").Find(db.Cond{"Expires": db.Before(time.Now())}).Delete()
func Cleanup() error {
return _db.Collection("session").Find(db.Cond{"Expires": db.Before(time.Now())}).Delete()
}

View 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"
)

View File

@ -2,6 +2,13 @@
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
package errors
import (
"encoding/base64"
"encoding/json"
"github.com/sirupsen/logrus"
)
type HTTPError interface {
Error() string
Code() int
@ -13,3 +20,61 @@ func ToHTTP(err error) (string, int) {
}
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
}

View File

@ -6,7 +6,7 @@ import (
"encoding/json"
"net/http"
"git.rob.mx/nidito/puerta/internal/auth"
"git.rob.mx/nidito/puerta/internal/user"
"github.com/julienschmidt/httprouter"
"github.com/sirupsen/logrus"
"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) {
users := []*auth.User{}
users := []*user.User{}
if err := _db.Collection("user").Find().All(&users); err != nil {
sendError(w, err)
return
@ -40,37 +40,37 @@ func listUsers(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
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)
res := &auth.User{}
res := &user.User{}
if err := dec.Decode(&res); err != nil {
return nil, err
}
logrus.Debugf("Unserialized user data: %v", res)
if user == nil {
user = &auth.User{
if u == nil {
u = &user.User{
Handle: res.Handle,
}
}
user.Name = res.Name
user.Expires = res.Expires
user.Greeting = res.Greeting
user.IsAdmin = res.IsAdmin
user.Require2FA = res.Require2FA
user.Schedule = res.Schedule
user.TTL = res.TTL
u.Name = res.Name
u.Expires = res.Expires
u.Greeting = res.Greeting
u.IsAdmin = res.IsAdmin
u.Require2FA = res.Require2FA
u.Schedule = res.Schedule
u.TTL = res.TTL
if res.Password != "" {
password, err := bcrypt.GenerateFromPassword([]byte(res.Password), bcrypt.DefaultCost)
if err != nil {
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) {
var user *auth.User
var user *user.User
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)
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) {
var user *auth.User
if err := _db.Collection("user").Find(db.Cond{"handle": params.ByName("id")}).One(user); err != nil {
logrus.Infof("updating user: %s", params.ByName("id"))
var user *user.User
if err := _db.Get(user, db.Cond{"handle": params.ByName("id")}); err != nil {
http.NotFound(w, r)
return
}

View File

@ -11,31 +11,26 @@
<link rel="stylesheet" href="/static/index.css" />
<style>
#user-list {
display: flex;
display: grid;
padding: 0;
margin: 0;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
grid-auto-rows: minmax(100px, auto);
position: relative;
}
#main-nav a {
color: #fff
}
user-info-panel {
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 {
table {
width: 100%;
}
.rex-record {
font-size: .8em;
}
</style>
</head>
<body>
@ -57,22 +52,88 @@
<style>
@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 {
list-style: none;
}
</style>
<li class="user-info-panel">
<header>
<button class="user-edit"></button>
<h3>Alguien</h3>
<code><pre>alguien</pre></code>
<button class="user-edit">Modificar</button>
<div class="user-info-meta">
<code>alguien</code>
</div>
</header>
<form action="/api/user/:id" class="user-info-panel-details hidden">
<label for="name">Nombre</label>
<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!" />
<label for="password">Password</label>
@ -87,14 +148,18 @@
<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" />
<div>
<input type="checkbox" name="is_admin" /><label for="admin">Admin?</label>
</div>
<label for="admin">Requiere 2FA?</label>
<input type="checkbox" name="second_factor" />
<div>
<input type="checkbox" name="second_factor" /><label for="admin">Requiere 2FA?</label>
</div>
<button class="user-delete">Eliminar</button>
<button class="user-save">Guardar cambios</button>
<div id="actions">
<button class="user-delete">Eliminar</button>
<button class="user-save">Guardar cambios</button>
</div>
</form>
</li>
</template>
@ -109,7 +174,7 @@
<label for="name">Nombre</label>
<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!" />
<label for="password">Password</label>
@ -124,11 +189,13 @@
<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" />
<div>
<input type="checkbox" name="is_admin" /><label for="admin">Admin?</label>
</div>
<label for="second_factor">Requiere 2FA?</label>
<input type="checkbox" name="second_factor" />
<div>
<input type="checkbox" name="second_factor" /><label for="admin">Requiere 2FA?</label>
</div>
<button id="create-user-submit" type="submit">Crear</button>
</form>
@ -137,6 +204,14 @@
<section id="registro" class="hidden">
<h2>Entradas recientes</h2>
<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>
<tr>
<th>ts</th>

View File

@ -13,6 +13,7 @@ import (
"git.rob.mx/nidito/puerta/internal/auth"
"git.rob.mx/nidito/puerta/internal/door"
"git.rob.mx/nidito/puerta/internal/errors"
"git.rob.mx/nidito/puerta/internal/user"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/julienschmidt/httprouter"
"github.com/sirupsen/logrus"
@ -33,8 +34,10 @@ var adminTemplate []byte
var staticFiles embed.FS
type HTTPConfig struct {
Listen int `yaml:"listen"`
Domain string `yaml:"domain"`
// Listen is a hostname:port
Listen string `yaml:"listen"`
// Origin describes the http origins to allow
Origin string `yaml:"domain"`
}
type Config struct {
@ -49,8 +52,8 @@ func ConfigDefaults(dbPath string) *Config {
return &Config{
DB: dbPath,
HTTP: &HTTPConfig{
Listen: 8000,
Domain: "localhost",
Listen: "localhost:8000",
Origin: "http://localhost:8000",
},
}
}
@ -61,20 +64,19 @@ type auditLog struct {
SecondFactor bool `db:"second_factor" json:"second_factor"`
Failure string `db:"failure" json:"failure"`
Err string `db:"error" json:"error"`
Success bool `db:"success" json:"success"`
IpAddress string `db:"ip_address" json:"ip_address"`
UserAgent string `db:"user_agent" json:"user_agent"`
}
func newAuditLog(r *http.Request, err error) *auditLog {
user := auth.UserFromContext(r)
u := user.FromContext(r)
ip := r.RemoteAddr
ua := r.Header.Get("user-agent")
al := &auditLog{
Timestamp: time.Now().UTC().Format(time.RFC3339),
User: user.Handle,
SecondFactor: user.Require2FA,
User: u.Handle,
SecondFactor: u.Require2FA,
IpAddress: ip,
UserAgent: ua,
}
@ -129,7 +131,7 @@ func CORS(w http.ResponseWriter, r *http.Request) {
func rex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var err error
user := r.Context().Value(auth.ContextUser).(*auth.User)
u := user.FromContext(r)
defer func() {
_, 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 {
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)
return
}
err = door.RequestToEnter(user.Name)
err = door.RequestToEnter(u.Name)
if err != nil {
message, code := errors.ToHTTP(err)
@ -179,19 +181,17 @@ func Initialize(config *Config) (http.Handler, error) {
return nil, err
}
uri := fmt.Sprintf("http://%s:%d", config.HTTP.Domain, config.HTTP.Listen)
wan, err := webauthn.New(&webauthn.Config{
RPDisplayName: config.Name,
RPID: config.HTTP.Domain,
RPOrigins: []string{uri, fmt.Sprintf("http://%s:%d", config.HTTP.Domain, 8080)},
RPID: config.HTTP.Origin,
RPOrigins: []string{config.HTTP.Listen},
// RPIcon: "https://go-webauthn.local/logo.png",
})
if err != nil {
return nil, err
}
am := auth.NewManager(wan, _db)
auth.Initialize(wan, _db)
serverRoot, err := fs.Sub(staticFiles, "static")
if err != nil {
@ -200,19 +200,23 @@ func Initialize(config *Config) (http.Handler, error) {
router.ServeFiles("/static/*filepath", http.FS(serverRoot))
router.GET("/login", renderTemplate(loginTemplate))
router.GET("/", am.RequireAuthOrRedirect(renderTemplate(indexTemplate), "/login"))
router.POST("/api/login", am.NewSession)
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))))
router.GET("/", auth.RequireAuthOrRedirect(renderTemplate(indexTemplate), "/login"))
router.GET("/admin", auth.RequireAdmin(renderTemplate(adminTemplate)))
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 {

View File

@ -1,28 +1,71 @@
import * as webauthn from "./webauthn.js"
// const host = document.location.protocol + "//" + document.location.host
const host = "http://localhost:8081"
const host = document.location.protocol + "//" + document.location.host
// 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 {
constructor(user) {
super()
}
connectedCallback() {
let template = document.getElementById("user-info-panel")
const shadowRoot = this.attachShadow({ mode: "open" })
const panel = template.content.cloneNode(true)
let handle = user.handle
panel.querySelector('h3').innerHTML = user.name
panel.querySelector('input[name=name]').value = user.name
let handle = this.getAttribute("handle")
panel.querySelector('h3').innerHTML = this.getAttribute("name")
panel.querySelector('input[name=name]').value = this.getAttribute("name")
panel.querySelector('form').action = panel.querySelector('form').action.replace(":id", handle)
panel.querySelector('pre').textContent = handle
const form = panel.querySelector('form')
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=schedule]').value = user.schedule
panel.querySelector('input[name=expires]').value = user.expires
panel.querySelector('input[name=max_ttl]').value = user.max_ttl
panel.querySelector('input[name=is_admin]').checked = user.is_admin
panel.querySelector('input[name=second_factor]').checked = user.second_factor
panel.querySelector('input[name=greeting]').value = this.getAttribute("greeting")
if (this.hasAttribute('schedule')){
panel.querySelector('input[name=schedule]').value = this.getAttribute("schedule")
}
if (this.hasAttribute('expires')){
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)
}
}
@ -35,7 +78,7 @@ class REXRow extends HTMLElement {
const shadowRoot = this.attachShadow({ mode: "open" })
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-status').innerHTML = !rex.error ? "ok" : `<strong>${rex.error}</strong> ${rex.failure}`
row.querySelector('.log-record-second_factor').innerText = rex.second_factor ? "✓" : ""
@ -45,7 +88,7 @@ class REXRow extends HTMLElement {
shadowRoot.appendChild(row)
}
}
customElements.define("rex-record", REXRow)
customElements.define("rex-record", REXRow, {extends: "tr"})
async function fetchUsers() {
console.debug("fetching users")
@ -64,7 +107,16 @@ async function fetchUsers() {
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() {
@ -87,21 +139,24 @@ async function fetchLog() {
document.querySelector("#rex-records").replaceChildren(...json.map(rex => {
const tr = document.createElement("tr")
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}`
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-status">${status}</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-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))
return tr
}))
}
async function CreateUser(form) {
let user = Object.fromEntries(new FormData(form))
function userFromForm(form) {
const user = Object.fromEntries(new FormData(form))
delete(user.id)
if (user.expires != "") {
user.expires = (new Date(user.expires)).toISOString()
@ -117,9 +172,32 @@ async function CreateUser(form) {
delete(user.schedule)
}
user.is_admin = user.is_admin == "on"
user.second_factor = user.second_factor == "on"
return user
}
user.admin = (user.admin == "on")
user.second_factor = (user.second_factor == "on")
async function UpdateUser(form) {
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"), {
credentials: "include",
@ -133,12 +211,11 @@ async function CreateUser(form) {
if (!response.ok) {
throw new Error("Could not create user:", response)
}
window.location.reload()
form.reset()
window.location.hash = "#invitades"
}
async function switchTab() {
let tabName = window.location.hash.toLowerCase().replace("#", "")
let activate = async () => true

View File

@ -1,3 +1,5 @@
/* SPDX-License-Identifier: Apache-2.0
Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx> */
button {
background: rgba(255,255,255,.6);
font-family: "Aestetico", sans-serif;
@ -65,6 +67,13 @@ input {
font-size: 1.5em;
width: 100%;
max-width: 50vw;
box-sizing: border-box;
}
input[type=checkbox] {
display: inline-block;
width: 1em;
height: .8em;
}
@media screen and (max-width: 768px) {

View File

@ -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 form = document.querySelector("#open")
import * as webauthn from "./webauthn.js"
// const host = document.location.protocol + "//" + document.location.host
const host = "http://localhost:8081"
const host = document.location.protocol + "//" + document.location.host
// const host = "http://localhost:8081"
async function RequestToEnter() {
console.debug("requesting to enter")

View File

@ -1,3 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
const button = document.querySelector("#auth")
const form = document.querySelector("#login")

View File

@ -1,3 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
importScripts(
'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 => {
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(
caches.open("pwa-assets")
.then(cache => {
@ -27,4 +29,4 @@ self.addEventListener('fetch', event => {
const cacheFirst = new workbox.strategies.CacheFirst();
event.respondWith(cacheFirst.handle({request: event.request}));
}
});
});

View File

@ -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 charsToEncode = /[\u007f-\uffff]/g;
@ -56,6 +58,7 @@ async function register(challenge) {
console.debug(`webauthn: registering credentials with server: ${JSON.stringify(credential)}`)
let response = await window.fetch("/api/webauthn/register", {
credentials: "include",
method: "POST",
body: JSON.stringify(credential),
headers: {
'Content-type': 'application/json'

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

View File

@ -1,8 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
package auth
package user
import (
"database/sql"
"encoding/json"
"fmt"
"strconv"
@ -32,24 +33,37 @@ func parseHour(src string) (float64, error) {
}
return 0.0, fmt.Errorf("unknown format for hour: %s", hm)
}
type UserSchedule struct {
type Schedule struct {
src string
days []int
hours []float64
}
func (d UserSchedule) MarshalDB() (any, error) {
func (d Schedule) MarshalDB() (any, error) {
return json.Marshal(d.src)
}
func (d UserSchedule) MarshalJSON() ([]byte, error) {
func (d Schedule) MarshalJSON() ([]byte, error) {
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, " ") {
kvSlice := strings.Split(kv, "=")
key := kvSlice[0]
@ -64,7 +78,7 @@ func (d *UserSchedule) Parse() error {
if err != nil {
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}
case "hours":
from, err := parseHour(values[0])
@ -75,39 +89,37 @@ func (d *UserSchedule) Parse() error {
if err != nil {
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}
}
}
return nil
}
func (d *UserSchedule) UnmarshalDB(value any) error {
func (d *Schedule) Scan(value any) error {
if value == nil {
return nil
}
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
}
parsed := UserSchedule{src: src}
if err := parsed.Parse(); err != nil {
return err
}
*d = parsed
return nil
}
func (d *UserSchedule) UnmarshalJSON(value []byte) error {
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 {
func (sch *Schedule) AllowedAt(t time.Time) bool {
weekDay := int(t.Weekday())
h, m, s := t.Clock()
fractionalHour := float64(h) + (float64(m*60.0+s) / 3600.0)
@ -128,7 +140,7 @@ func (sch *UserSchedule) AllowedAt(t time.Time) bool {
return true
}
var _ = (db.Unmarshaler(&UserSchedule{}))
var _ = (db.Marshaler(&UserSchedule{}))
var _ = (json.Marshaler(&UserSchedule{}))
var _ = (json.Unmarshaler(&UserSchedule{}))
var _ sql.Scanner = &Schedule{}
var _ db.Marshaler = &Schedule{}
var _ json.Marshaler = &Schedule{}
var _ json.Unmarshaler = &Schedule{}

108
internal/user/ttl.go Normal file
View 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{}

View File

@ -1,6 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
package auth
package user
import (
"encoding/json"
@ -8,30 +8,16 @@ import (
"net/http"
"time"
"git.rob.mx/nidito/puerta/internal/constants"
"git.rob.mx/nidito/puerta/internal/errors"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/sirupsen/logrus"
"github.com/upper/db/v4"
"golang.org/x/crypto/bcrypt"
)
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
}
func UserFromContext(req *http.Request) *User {
u := req.Context().Value(ContextUser)
func FromContext(req *http.Request) *User {
u := req.Context().Value(constants.ContextUser)
if u != nil {
return u.(*User)
@ -40,16 +26,16 @@ func UserFromContext(req *http.Request) *User {
}
type User struct {
ID int `db:"id,omitempty" json:"-"`
Expires *time.Time `db:"expires,omitempty" json:"expires"`
Greeting string `db:"greeting" json:"greeting"`
Handle string `db:"handle" json:"handle"`
IsAdmin bool `db:"is_admin" json:"is_admin"`
Name string `db:"name" json:"name"`
Password string `db:"password" json:"password"`
Require2FA bool `db:"second_factor" json:"second_factor"`
Schedule *UserSchedule `db:"schedule,omitempty" json:"schedule,omitempty"`
TTL *TTL `db:"max_ttl,omitempty" json:"max_ttl,omitempty"`
ID int `db:"id,omitempty" json:"-"`
Expires *UTCTime `db:"expires,omitempty" json:"expires,omitempty"`
Greeting string `db:"greeting" json:"greeting"`
Handle string `db:"handle" json:"handle"`
IsAdmin bool `db:"is_admin" json:"is_admin"`
Name string `db:"name" json:"name"`
Password string `db:"password" json:"password"`
Require2FA bool `db:"second_factor" json:"second_factor"`
Schedule *Schedule `db:"schedule,omitempty" json:"schedule,omitempty"`
TTL *TTL `db:"max_ttl,omitempty" json:"max_ttl,omitempty"`
credentials []*Credential
}
@ -137,17 +123,21 @@ func (user *User) IsAllowed(t time.Time) error {
func (user *User) Login(password string) error {
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
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() {
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
}
func (user *User) HasCredentials() bool {
return len(user.credentials) > 0
}
// implement interfaces
var _ = db.Record(&User{})
var _ = webauthn.User(&User{})
var _ db.Record = &User{}
var _ webauthn.User = &User{}

73
internal/user/utctime.go Normal file
View 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()
}

View File

@ -21,7 +21,7 @@ CREATE TABLE credential(
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(