diff --git a/README.md b/README.md index e69de29..ed56578 100644 --- a/README.md +++ b/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). diff --git a/cmd/admin/user.go b/cmd/admin/user.go index 31ae785..05c7960 100644 --- a/cmd/admin/user.go +++ b/cmd/admin/user.go @@ -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 }, diff --git a/cmd/server/main.go b/cmd/server/main.go index 15c6464..62d4115 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) }, } diff --git a/config.template.yaml b/config.template.yaml index 93cd8bb..4554c1a 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -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 \ No newline at end of file + listen: "localhost:8080" + origin: http://localhost:8080 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 282d92f..67ada4e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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) diff --git a/internal/auth/duration.go b/internal/auth/duration.go deleted file mode 100644 index e7312e6..0000000 --- a/internal/auth/duration.go +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright © 2022 Roberto Hidalgo -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()) -} diff --git a/internal/auth/errors.go b/internal/auth/errors.go deleted file mode 100644 index beeb50b..0000000 --- a/internal/auth/errors.go +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright © 2022 Roberto Hidalgo -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 -} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index ec8b716..52a07d9 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -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) diff --git a/internal/auth/session.go b/internal/auth/session.go index 72b3661..ad74243 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -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, diff --git a/internal/auth/webauthn.go b/internal/auth/webauthn.go index b64920b..ec75a4a 100644 --- a/internal/auth/webauthn.go +++ b/internal/auth/webauthn.go @@ -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() } diff --git a/internal/constants/contstants.go b/internal/constants/contstants.go new file mode 100644 index 0000000..e1b88dd --- /dev/null +++ b/internal/constants/contstants.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright © 2022 Roberto Hidalgo +package constants + +type AuthContext string + +const ( + ContextCookieName AuthContext = "_puerta" + ContextUser AuthContext = "_user" +) diff --git a/internal/errors/errors.go b/internal/errors/errors.go index e78b035..6d2091f 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -2,6 +2,13 @@ // Copyright © 2022 Roberto Hidalgo 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 +} diff --git a/internal/server/admin.go b/internal/server/admin.go index 12ead46..c4fc17f 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -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 } diff --git a/internal/server/admin.html b/internal/server/admin.html index 67cf32c..5d4e19f 100644 --- a/internal/server/admin.html +++ b/internal/server/admin.html @@ -11,31 +11,26 @@ @@ -57,22 +52,88 @@ @@ -109,7 +174,7 @@ - + @@ -124,11 +189,13 @@ - - +
+ +
- - +
+ +
@@ -137,6 +204,14 @@