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 (
|
||||
"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
|
||||
|
||||
},
|
||||
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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"
|
||||
"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)
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
}
|
||||
|
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>
|
||||
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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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")
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -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'
|
||||
|
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
|
||||
// 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
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
|
||||
// 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
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
|
||||
);
|
||||
|
||||
CREATE INDEX credential_user ON credential(id);
|
||||
CREATE INDEX credential_user ON credential(user);
|
||||
|
||||
|
||||
CREATE TABLE session(
|
||||
|
Loading…
Reference in New Issue
Block a user