moar webauthn and admin stuff
This commit is contained in:
parent
eee573eb47
commit
63b9a753e8
@ -3,9 +3,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@ -94,202 +91,3 @@ func (am *Manager) NewSession(w http.ResponseWriter, req *http.Request, ps httpr
|
||||
http.Redirect(w, req, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func (am *Manager) 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
|
||||
}
|
||||
|
||||
cookie, err := req.Cookie(string(ContextCookieName))
|
||||
if err != nil {
|
||||
logrus.Debugf("no cookie for user found in jar <%s>", req.Cookies())
|
||||
return req
|
||||
}
|
||||
|
||||
session := &SessionUser{}
|
||||
q := am.db.SQL().
|
||||
Select("s.token as token, ", "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))
|
||||
return req
|
||||
}
|
||||
|
||||
if session.Expired() {
|
||||
logrus.Debugf("expired cookie found in DB for jar <%s>", req.Cookies())
|
||||
w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Secure; Path=/;", ContextCookieName, "", -1))
|
||||
err := am.db.Collection("session").Find(db.Cond{"token": cookie.Value}).Delete()
|
||||
if err != nil {
|
||||
logrus.Errorf("could not purge expired session from DB: %s", err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
return req.WithContext(context.WithValue(req.Context(), ContextUser, &session.User))
|
||||
}()
|
||||
|
||||
handler(w, req, ps)
|
||||
}
|
||||
}
|
||||
|
||||
func (am *Manager) RequireAuthOrRedirect(handler httprouter.Handle, target string) httprouter.Handle {
|
||||
return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||
if req.Context().Value(ContextUser) == nil {
|
||||
http.Redirect(w, req, target, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
handler(w, req, ps)
|
||||
})
|
||||
}
|
||||
|
||||
func (am *Manager) Enforce2FA(handler httprouter.Handle) httprouter.Handle {
|
||||
return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||
if req.Context().Value(ContextUser) == nil {
|
||||
am.requestAuth(w, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user := req.Context().Value(ContextUser).(*User)
|
||||
if !user.Require2FA {
|
||||
handler(w, req, ps)
|
||||
return
|
||||
}
|
||||
|
||||
logrus.Debug("Enforcing 2fa for request")
|
||||
var err error
|
||||
err = user.FetchCredentials(am.db)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed fetching credentials: %s", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(user.credentials) == 0 {
|
||||
err = am.WebAuthnRegister(req)
|
||||
} else {
|
||||
err = am.WebAuthnLogin(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if wafc, ok := err.(WebAuthFlowChallenge); ok {
|
||||
w.WriteHeader(200)
|
||||
w.Header().Add("content-type", "application/json")
|
||||
w.Write([]byte(wafc.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
logrus.Errorf("Failed during webauthn flow: %s", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
handler(w, req, ps)
|
||||
})
|
||||
}
|
||||
|
||||
func (am *Manager) RequireAdmin(handler httprouter.Handle) httprouter.Handle {
|
||||
return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||
if req.Context().Value(ContextUser) == nil {
|
||||
am.requestAuth(w, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user := req.Context().Value(ContextUser).(*User)
|
||||
|
||||
if !user.IsAdmin {
|
||||
am.requestAuth(w, http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
handler(w, req, ps)
|
||||
})
|
||||
}
|
||||
|
||||
func (am *Manager) WebAuthnRegister(req *http.Request) error {
|
||||
user := UserFromContext(req)
|
||||
sd := am.sess.GetBytes(req.Context(), "wan-register")
|
||||
if sd == nil {
|
||||
logrus.Infof("Starting webauthn registration for %s", user.Name)
|
||||
options, sessionData, err := am.wan.BeginRegistration(user)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("error starting webauthn: %s", err)
|
||||
logrus.Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(&sessionData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
am.sess.Put(req.Context(), "wan-register", b.Bytes())
|
||||
|
||||
return WebAuthFlowChallenge{"register", &options}
|
||||
}
|
||||
|
||||
var sessionData webauthn.SessionData
|
||||
err := json.Unmarshal(sd, &sessionData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cred, err := am.wan.FinishRegistration(user, sessionData, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finishing webauthn registration: %s", err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cred)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error encoding webauthn credential for storage: %s", err)
|
||||
}
|
||||
credential := &Credential{
|
||||
UserID: user.ID,
|
||||
Data: string(data),
|
||||
}
|
||||
|
||||
_, err = am.db.Collection("credential").Insert(credential)
|
||||
return err
|
||||
}
|
||||
|
||||
func (am *Manager) WebAuthnLogin(req *http.Request) error {
|
||||
user := UserFromContext(req)
|
||||
sd := am.sess.GetBytes(req.Context(), "rex")
|
||||
if sd == nil {
|
||||
logrus.Infof("Starting webauthn login flow for %s", user.Name)
|
||||
|
||||
options, sessionData, err := am.wan.BeginLogin(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error starting webauthn login: %s", err)
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(&sessionData); err != nil {
|
||||
return fmt.Errorf("could not encode json: %s", err)
|
||||
}
|
||||
|
||||
am.sess.Put(req.Context(), "rex", b.Bytes())
|
||||
|
||||
return WebAuthFlowChallenge{"login", &options}
|
||||
}
|
||||
|
||||
var sessionData webauthn.SessionData
|
||||
err := json.Unmarshal(sd, &sessionData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = am.wan.FinishLogin(user, sessionData, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (am *Manager) Cleanup() error {
|
||||
return am.db.Collection("session").Find(db.Cond{"Expires": db.Before(time.Now())}).Delete()
|
||||
}
|
||||
|
@ -13,6 +13,9 @@ 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)
|
||||
@ -52,6 +55,3 @@ func (ttl *TTL) Seconds() int {
|
||||
d, _ := ttl.ToDuration()
|
||||
return int(d.Seconds())
|
||||
}
|
||||
|
||||
// var _ = (db.Unmarshaler(&TTL{}))
|
||||
// var _ = (db.Marshaler(&TTL{}))
|
||||
|
@ -3,6 +3,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
@ -47,6 +48,17 @@ func (c WebAuthFlowChallenge) Error() string {
|
||||
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")
|
||||
}
|
||||
|
149
internal/auth/middleware.go
Normal file
149
internal/auth/middleware.go
Normal file
@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/upper/db/v4"
|
||||
)
|
||||
|
||||
func (am *Manager) 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
|
||||
}
|
||||
|
||||
cookie, err := req.Cookie(string(ContextCookieName))
|
||||
if err != nil {
|
||||
logrus.Debugf("no cookie for user found in jar <%s>", req.Cookies())
|
||||
return req
|
||||
}
|
||||
|
||||
session := &SessionUser{}
|
||||
q := am.db.SQL().
|
||||
Select("s.token as token, ", "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))
|
||||
return req
|
||||
}
|
||||
|
||||
if session.Expired() {
|
||||
logrus.Debugf("expired cookie found in DB for jar <%s>", req.Cookies())
|
||||
w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Secure; Path=/;", ContextCookieName, "", -1))
|
||||
err := am.db.Collection("session").Find(db.Cond{"token": cookie.Value}).Delete()
|
||||
if err != nil {
|
||||
logrus.Errorf("could not purge expired session from DB: %s", err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
return req.WithContext(context.WithValue(req.Context(), 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)
|
||||
return
|
||||
}
|
||||
|
||||
handler(w, req, ps)
|
||||
})
|
||||
}
|
||||
|
||||
func (am *Manager) RequireAuthOrRedirect(handler httprouter.Handle, target string) httprouter.Handle {
|
||||
return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
|
||||
if req.Context().Value(ContextUser) == nil {
|
||||
http.Redirect(w, req, target, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
handler(w, req, ps)
|
||||
})
|
||||
}
|
||||
|
||||
func (am *Manager) 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 {
|
||||
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
err := am.WebAuthnFinishRegistration(req)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed during webauthn flow: %s", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
handler(w, req, ps)
|
||||
return
|
||||
}
|
||||
|
||||
logrus.Debug("Enforcing 2fa for request")
|
||||
var err error
|
||||
err = user.FetchCredentials(am.db)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed fetching credentials: %s", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(user.credentials) == 0 {
|
||||
err = am.WebAuthnBeginRegistration(req)
|
||||
} else {
|
||||
err = am.WebAuthnLogin(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if wafc, ok := err.(WebAuthFlowChallenge); ok {
|
||||
w.WriteHeader(200)
|
||||
w.Header().Add("content-type", "application/json")
|
||||
w.Header().Add("webauthn", wafc.Header())
|
||||
return
|
||||
}
|
||||
|
||||
logrus.Errorf("Failed during webauthn flow: %s", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
defer am.sess.RenewToken(req.Context())
|
||||
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)
|
||||
if !user.IsAdmin {
|
||||
am.requestAuth(w, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
handler(w, req, ps)
|
||||
})
|
||||
}
|
@ -49,14 +49,8 @@ func (d UserSchedule) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(d.src)
|
||||
}
|
||||
|
||||
func (d *UserSchedule) UnmarshalDB(b any) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(b.([]byte), &v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*d = UserSchedule{src: v}
|
||||
for _, kv := range strings.Split(v, " ") {
|
||||
func (d *UserSchedule) Parse() error {
|
||||
for _, kv := range strings.Split(d.src, " ") {
|
||||
kvSlice := strings.Split(kv, "=")
|
||||
key := kvSlice[0]
|
||||
values := strings.Split(kvSlice[1], "-")
|
||||
@ -85,7 +79,31 @@ func (d *UserSchedule) UnmarshalDB(b any) error {
|
||||
d.hours = []float64{from, until}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *UserSchedule) UnmarshalDB(value any) error {
|
||||
var src string
|
||||
if err := json.Unmarshal(value.([]byte), &src); 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
|
||||
}
|
||||
|
||||
@ -112,3 +130,5 @@ func (sch *UserSchedule) AllowedAt(t time.Time) bool {
|
||||
|
||||
var _ = (db.Unmarshaler(&UserSchedule{}))
|
||||
var _ = (db.Marshaler(&UserSchedule{}))
|
||||
var _ = (json.Marshaler(&UserSchedule{}))
|
||||
var _ = (json.Unmarshaler(&UserSchedule{}))
|
||||
|
@ -40,16 +40,16 @@ func UserFromContext(req *http.Request) *User {
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `db:"id" json:"-"`
|
||||
Handle string `db:"handle" json:"handle"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Password string `db:"password" json:"-"`
|
||||
Schedule *UserSchedule `db:"schedule,omitempty" json:"schedule"`
|
||||
ID int `db:"id,omitempty" json:"-"`
|
||||
Expires *time.Time `db:"expires,omitempty" json:"expires"`
|
||||
Greeting string `db:"greeting" json:"greeting"`
|
||||
TTL *TTL `db:"max_ttl,omitempty" json:"max_ttl"`
|
||||
Require2FA bool `db:"second_factor" json:"second_factor"`
|
||||
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"`
|
||||
credentials []*Credential
|
||||
}
|
||||
|
||||
@ -110,6 +110,14 @@ func (o *User) UnmarshalJSON(b []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) MarshalJSON() ([]byte, error) {
|
||||
// prevent calling ourselves by subtyping
|
||||
type alias User
|
||||
x := alias(*u)
|
||||
x.Password = ""
|
||||
return json.Marshal(x)
|
||||
}
|
||||
|
||||
func (user *User) Expired() bool {
|
||||
return user.Expires != nil && user.Expires.Before(time.Now())
|
||||
}
|
||||
|
121
internal/auth/webauthn.go
Normal file
121
internal/auth/webauthn.go
Normal file
@ -0,0 +1,121 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/upper/db/v4"
|
||||
)
|
||||
|
||||
const SessionNameWANAuth = "wan-auth"
|
||||
const SessionNameWANRegister = "wan-register"
|
||||
const HeaderNameWAN = "webauthn"
|
||||
|
||||
func (am *Manager) WebAuthnBeginRegistration(req *http.Request) error {
|
||||
user := UserFromContext(req)
|
||||
logrus.Infof("Starting webauthn registration for %s", user.Name)
|
||||
options, sessionData, err := am.wan.BeginRegistration(user)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("error starting webauthn: %s", err)
|
||||
logrus.Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(&sessionData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
am.sess.Put(req.Context(), SessionNameWANRegister, b.Bytes())
|
||||
return WebAuthFlowChallenge{"register", &options}
|
||||
}
|
||||
|
||||
func (am *Manager) WebAuthnFinishRegistration(req *http.Request) error {
|
||||
user := UserFromContext(req)
|
||||
sd := am.sess.PopBytes(req.Context(), SessionNameWANRegister)
|
||||
if sd == nil {
|
||||
return fmt.Errorf("error finishing webauthn registration: no session found for user")
|
||||
}
|
||||
|
||||
var sessionData webauthn.SessionData
|
||||
err := json.Unmarshal(sd, &sessionData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cred, err := am.wan.FinishRegistration(user, sessionData, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finishing webauthn registration: %s", err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cred)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error encoding webauthn credential for storage: %s", err)
|
||||
}
|
||||
credential := &Credential{
|
||||
UserID: user.ID,
|
||||
Data: string(data),
|
||||
}
|
||||
|
||||
_, err = am.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)
|
||||
if sd == nil {
|
||||
logrus.Infof("Starting webauthn login flow for %s", user.Name)
|
||||
|
||||
options, sessionData, err := am.wan.BeginLogin(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error starting webauthn login: %s", err)
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(&sessionData); err != nil {
|
||||
return fmt.Errorf("could not encode json: %s", err)
|
||||
}
|
||||
|
||||
am.sess.Put(req.Context(), SessionNameWANAuth, b.Bytes())
|
||||
|
||||
return WebAuthFlowChallenge{"login", &options}
|
||||
}
|
||||
|
||||
var sessionData webauthn.SessionData
|
||||
err := json.Unmarshal(sd, &sessionData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
challengeResponse := req.Header.Get(HeaderNameWAN)
|
||||
if challengeResponse == "" {
|
||||
return fmt.Errorf("missing webauthn header")
|
||||
}
|
||||
|
||||
challengeBytes, err := base64.StdEncoding.DecodeString(challengeResponse)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unparseable webauthn header value")
|
||||
}
|
||||
|
||||
response, err := protocol.ParseCredentialRequestResponseBody(bytes.NewBuffer(challengeBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse webauthn request into protocol: %w", err)
|
||||
}
|
||||
|
||||
_, err = am.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()
|
||||
}
|
@ -5,8 +5,6 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.rob.mx/nidito/puerta/internal/auth"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
@ -43,55 +41,35 @@ func listUsers(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
}
|
||||
|
||||
func userFromRequest(r *http.Request, user *auth.User) (*auth.User, error) {
|
||||
r.ParseForm()
|
||||
dec := json.NewDecoder(r.Body)
|
||||
res := &auth.User{}
|
||||
if err := dec.Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logrus.Debugf("Unserialized user data: %v", res)
|
||||
|
||||
if user == nil {
|
||||
user = &auth.User{}
|
||||
user = &auth.User{
|
||||
Handle: res.Handle,
|
||||
}
|
||||
}
|
||||
|
||||
isAdmin, err := strconv.ParseBool(r.FormValue("is_admin"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secondFactor, err := strconv.ParseBool(r.FormValue("second_factor"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.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
|
||||
|
||||
user.Handle = r.FormValue("handle")
|
||||
user.Name = r.FormValue("name")
|
||||
user.Greeting = r.FormValue("greeting")
|
||||
user.Require2FA = secondFactor
|
||||
user.IsAdmin = isAdmin
|
||||
|
||||
if r.FormValue("password") != "" {
|
||||
password, err := bcrypt.GenerateFromPassword([]byte(r.FormValue("password")), bcrypt.DefaultCost)
|
||||
if res.Password != "" {
|
||||
password, err := bcrypt.GenerateFromPassword([]byte(res.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Password = string(password)
|
||||
}
|
||||
|
||||
if r.Form.Has("schedule") {
|
||||
schedule := &auth.UserSchedule{}
|
||||
err := schedule.UnmarshalDB([]byte(r.FormValue("schedule")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Schedule = schedule
|
||||
}
|
||||
|
||||
if r.Form.Has("expires") {
|
||||
expires, err := time.Parse(time.RFC3339, r.FormValue("expires"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Expires = &expires
|
||||
}
|
||||
|
||||
if r.Form.Has("max_ttl") {
|
||||
*user.TTL = auth.TTL(r.FormValue("max_ttl"))
|
||||
}
|
||||
|
||||
return user, nil
|
||||
|
||||
}
|
||||
@ -153,3 +131,14 @@ func deleteUser(w http.ResponseWriter, r *http.Request, params httprouter.Params
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func rexRecords(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
records := []*auditLog{}
|
||||
err := _db.Collection("log").Find().OrderBy("-timestamp").Limit(20).All(&records)
|
||||
if err != nil {
|
||||
sendError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, records)
|
||||
}
|
||||
|
@ -9,67 +9,68 @@
|
||||
<link rel="stylesheet" href="https://cdn.rob.mx/css/fonts.css" />
|
||||
<link rel="stylesheet" href="https://cdn.rob.mx/nidito/index.css" />
|
||||
<link rel="stylesheet" href="/static/index.css" />
|
||||
<style>
|
||||
#user-list {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#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 {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header id="main-header">
|
||||
<div class="container">
|
||||
<h1>Puerta</h1>
|
||||
<p>Admin</p>
|
||||
<nav id="main-nav">
|
||||
<a class="nav-item" href="#invitades">Invitades</a>
|
||||
<a class="nav-item" href="#crear">Crear Invitade</a>
|
||||
<a class="nav-item" href="#registro">Registro</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="container">
|
||||
<section id="create-user" style="display:none">
|
||||
<h2>Crear usuario</h2>
|
||||
<form id="create-user" method="post" action="/api/user">
|
||||
<label for="user">Handle</label>
|
||||
<input name="handle" placeholder="joao" autocorrect="off"/>
|
||||
<section id="invitades" class="hidden">
|
||||
<h2>Invitades</h2>
|
||||
<ul id="user-list"></ul>
|
||||
<template id="user-info-panel">
|
||||
<style>
|
||||
@import "/static/index.css";
|
||||
|
||||
<label for="name">Nombre</label>
|
||||
<input name="name" placeholder="João Gilberto" />
|
||||
.user-info-panel {
|
||||
list-style: none;
|
||||
|
||||
<label for="greeting">Greeting</label>
|
||||
<input name="greeting" placeholder="Olá Joãzinho!" />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" />
|
||||
|
||||
<label for="schedule">Horarios</label>
|
||||
<input type="text" name="schedule" placeholder="days=1-5 hours=8-20:35" autocorrect="off"/>
|
||||
|
||||
<label for="expires">Expires</label>
|
||||
<input type="datetime-local" name="expires" placeholder="2023-01-01T00:00:00Z" />
|
||||
|
||||
<label for="max_ttl">TTL</label>
|
||||
<input type="text" name="max_ttl" placeholder="30d" autocorrect="off"/>
|
||||
|
||||
<label for="admin">Admin?</label>
|
||||
<input type="checkbox" name="is_admin" />
|
||||
|
||||
<label for="second_factor">Requiere 2FA?</label>
|
||||
<input type="checkbox" name="second_factor" />
|
||||
|
||||
<button id="create-user-submit" type="submit">Crear</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="user-list">
|
||||
<h2>Usuarios</h2>
|
||||
<ul></ul>
|
||||
</section>
|
||||
|
||||
|
||||
</main>
|
||||
<script type="module" src="/static/admin.js"></script>
|
||||
|
||||
<template id="user-info-panel"><li class="user-info-panel">
|
||||
}
|
||||
</style>
|
||||
<li class="user-info-panel">
|
||||
<header>
|
||||
<h3><slot name="name">Alguien</h3>
|
||||
<h3>Alguien</h3>
|
||||
<code><pre>alguien</pre></code>
|
||||
<button class="user-edit">Modificar</button>
|
||||
</header>
|
||||
<form action="/api/user/:id" class="user-info-panel-details">
|
||||
<form action="/api/user/:id" class="user-info-panel-details hidden">
|
||||
<label for="name">Nombre</label>
|
||||
<input name="name" value="" placeholder="João Gilberto" />
|
||||
<input name="name" value="" placeholder="João Gilberto" required />
|
||||
|
||||
<label for="greeting">Greeting</label>
|
||||
<input name="greeting" placeholder="Olá Joãzinho!" />
|
||||
@ -95,6 +96,71 @@
|
||||
<button class="user-delete">Eliminar</button>
|
||||
<button class="user-save">Guardar cambios</button>
|
||||
</form>
|
||||
</li></template>
|
||||
</li>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section id="crear" class="hidden">
|
||||
<h2>Crear Invitade</h2>
|
||||
<form id="create-user" method="post" action="/api/user">
|
||||
<label for="user">Handle</label>
|
||||
<input name="handle" placeholder="joao" autocorrect="off" required />
|
||||
|
||||
<label for="name">Nombre</label>
|
||||
<input name="name" placeholder="João Gilberto" required />
|
||||
|
||||
<label for="greeting">Greeting</label>
|
||||
<input name="greeting" placeholder="Olá Joãzinho!" />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" required />
|
||||
|
||||
<label for="schedule">Horarios</label>
|
||||
<input type="text" name="schedule" placeholder="days=1-5 hours=8-20:35" autocorrect="off"/>
|
||||
|
||||
<label for="expires">Expires</label>
|
||||
<input type="datetime-local" name="expires" placeholder="2023-01-01T00:00:00Z" />
|
||||
|
||||
<label for="max_ttl">TTL</label>
|
||||
<input type="text" name="max_ttl" placeholder="30d" autocorrect="off"/>
|
||||
|
||||
<label for="admin">Admin?</label>
|
||||
<input type="checkbox" name="is_admin" />
|
||||
|
||||
<label for="second_factor">Requiere 2FA?</label>
|
||||
<input type="checkbox" name="second_factor" />
|
||||
|
||||
<button id="create-user-submit" type="submit">Crear</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="registro" class="hidden">
|
||||
<h2>Entradas recientes</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ts</th>
|
||||
<th>nombre</th>
|
||||
<th>status</th>
|
||||
<th>2fa</th>
|
||||
<th>ip</th>
|
||||
<th>ua</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rex-records">
|
||||
</tbody>
|
||||
</table>
|
||||
<template id="rex-record">
|
||||
<style>
|
||||
@import "/static/index.css";
|
||||
</style>
|
||||
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/@teamhanko/hanko-webauthn@latest/dist/browser-global/hanko-webauthn.browser-global.js"></script>
|
||||
|
||||
<script type="module" src="/static/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -9,7 +9,6 @@
|
||||
<link rel="stylesheet" href="https://cdn.rob.mx/css/fonts.css" />
|
||||
<link rel="stylesheet" href="https://cdn.rob.mx/nidito/index.css" />
|
||||
<link rel="stylesheet" href="/static/index.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@teamhanko/hanko-webauthn@latest/dist/browser-global/hanko-webauthn.browser-global.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header id="main-header">
|
||||
@ -23,6 +22,7 @@
|
||||
<button id="rex">Abrir</button>
|
||||
</form>
|
||||
</main>
|
||||
<script src="/static/index.js" async="async"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@teamhanko/hanko-webauthn@latest/dist/browser-global/hanko-webauthn.browser-global.js"></script>
|
||||
<script type="module" src="/static/index.js" async="async"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -56,14 +56,14 @@ func ConfigDefaults(dbPath string) *Config {
|
||||
}
|
||||
|
||||
type auditLog struct {
|
||||
Timestamp time.Time `db:"timestamp"`
|
||||
User string `db:"user"`
|
||||
SecondFactor bool `db:"second_factor"`
|
||||
Failure string `db:"failure"`
|
||||
Err string `db:"error"`
|
||||
Success bool `db:"success"`
|
||||
IpAddress string `db:"ip_address"`
|
||||
UserAgent string `db:"user_agent"`
|
||||
Timestamp string `db:"timestamp" json:"timestamp"`
|
||||
User string `db:"user" json:"user"`
|
||||
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 {
|
||||
@ -72,7 +72,7 @@ func newAuditLog(r *http.Request, err error) *auditLog {
|
||||
ua := r.Header.Get("user-agent")
|
||||
|
||||
al := &auditLog{
|
||||
Timestamp: time.Now(),
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
User: user.Handle,
|
||||
SecondFactor: user.Require2FA,
|
||||
IpAddress: ip,
|
||||
@ -90,12 +90,37 @@ func newAuditLog(r *http.Request, err error) *auditLog {
|
||||
return al
|
||||
}
|
||||
|
||||
func allowCORS(handler httprouter.Handle) httprouter.Handle {
|
||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
header := w.Header()
|
||||
header.Set("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE")
|
||||
header.Set("Access-Control-Allow-Origin", "http://localhost:8080")
|
||||
header.Set("Access-Control-Allow-Credentials", "true")
|
||||
header.Set("Access-Control-Allow-Headers", "content-type,webauthn")
|
||||
header.Set("Access-Control-Expose-Headers", "webauthn")
|
||||
|
||||
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
|
||||
// Set CORS headers
|
||||
// Adjust status code to 204
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if handler != nil {
|
||||
handler(w, r, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CORS(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Access-Control-Request-Method") != "" {
|
||||
// Set CORS headers
|
||||
header := w.Header()
|
||||
header.Set("Access-Control-Allow-Methods", r.Header.Get("Allow"))
|
||||
header.Set("Access-Control-Allow-Origin", "")
|
||||
header.Set("Access-Control-Allow-Origin", "http://localhost:8080")
|
||||
header.Set("Access-Control-Allow-Credentials", "true")
|
||||
header.Set("Access-Control-Allow-Headers", "content-type,webauthn")
|
||||
header.Set("Access-Control-Expose-Headers", "webauthn")
|
||||
}
|
||||
|
||||
// Adjust status code to 204
|
||||
@ -159,7 +184,7 @@ func Initialize(config *Config) (http.Handler, error) {
|
||||
wan, err := webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: config.Name,
|
||||
RPID: config.HTTP.Domain,
|
||||
RPOrigins: []string{uri},
|
||||
RPOrigins: []string{uri, fmt.Sprintf("http://%s:%d", config.HTTP.Domain, 8080)},
|
||||
// RPIcon: "https://go-webauthn.local/logo.png",
|
||||
})
|
||||
if err != nil {
|
||||
@ -177,13 +202,15 @@ func Initialize(config *Config) (http.Handler, error) {
|
||||
router.GET("/login", renderTemplate(loginTemplate))
|
||||
router.GET("/", am.RequireAuthOrRedirect(renderTemplate(indexTemplate), "/login"))
|
||||
router.POST("/api/login", am.NewSession)
|
||||
router.POST("/api/rex", am.Enforce2FA(rex))
|
||||
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/user", am.RequireAdmin(listUsers))
|
||||
router.GET("/api/user/:id", am.RequireAdmin(getUser))
|
||||
router.PUT("/api/user", am.RequireAdmin(am.Enforce2FA(createUser)))
|
||||
router.POST("/api/user/:id", am.RequireAdmin(am.Enforce2FA(updateUser)))
|
||||
router.DELETE("/api/user/:id", am.RequireAdmin(am.Enforce2FA(deleteUser)))
|
||||
router.GET("/api/log", allowCORS(am.RequireAdmin(rexRecords)))
|
||||
router.GET("/api/user", allowCORS(am.RequireAdmin(listUsers)))
|
||||
router.GET("/api/user/:id", allowCORS(am.RequireAdmin(getUser)))
|
||||
router.POST("/api/user", allowCORS(am.RequireAdmin(am.Enforce2FA(createUser))))
|
||||
router.POST("/api/user/:id", allowCORS(am.RequireAdmin(am.Enforce2FA(updateUser))))
|
||||
router.DELETE("/api/user/:id", allowCORS(am.RequireAdmin(am.Enforce2FA(deleteUser))))
|
||||
|
||||
return am.Route(router), nil
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
const button = document.querySelector("#open button")
|
||||
const form = document.querySelector("#open")
|
||||
import * as webauthn from "./webauthn.js"
|
||||
|
||||
const userList = document.querySelector("#user-list > ul")
|
||||
// const host = document.location.protocol + "//" + document.location.host
|
||||
const host = "http://localhost:8081"
|
||||
|
||||
class UserInfoPanel extends HTMLElement {
|
||||
constructor(user) {
|
||||
@ -26,15 +26,30 @@ class UserInfoPanel extends HTMLElement {
|
||||
shadowRoot.appendChild(panel)
|
||||
}
|
||||
}
|
||||
customElements.define("user-info-panel", UserInfoPanel)
|
||||
|
||||
customElements.define(
|
||||
"user-info-panel",
|
||||
UserInfoPanel
|
||||
);
|
||||
class REXRow extends HTMLElement {
|
||||
constructor(rex) {
|
||||
super()
|
||||
let template = document.getElementById("rex-record")
|
||||
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-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 ? "✓" : ""
|
||||
row.querySelector('.log-record-ip_address').innerText = rex.ip_address
|
||||
row.querySelector('.log-record-user_agent').innerText = rex.user_agent
|
||||
|
||||
shadowRoot.appendChild(row)
|
||||
}
|
||||
}
|
||||
customElements.define("rex-record", REXRow)
|
||||
|
||||
async function fetchUsers() {
|
||||
console.debug("fetching users")
|
||||
let response = await window.fetch("/api/user")
|
||||
let response = await window.fetch(`${host}/api/user`, {credentials: "include"})
|
||||
|
||||
if (!response.ok) {
|
||||
alert("Could not load users")
|
||||
@ -49,26 +64,124 @@ async function fetchUsers() {
|
||||
return
|
||||
}
|
||||
|
||||
json.forEach(u => {
|
||||
const ip = new UserInfoPanel(u)
|
||||
ip.setAttribute("data-name", u.name)
|
||||
ip.setAttribute("data-handle", u.handle)
|
||||
document.querySelector("#user-list").replaceChildren(...json.map(u => new UserInfoPanel(u)))
|
||||
}
|
||||
|
||||
ip.setAttribute('data-greeting', u.greeting)
|
||||
ip.setAttribute('data-schedule', u.schedule)
|
||||
ip.setAttribute('data-expires', u.expires)
|
||||
ip.setAttribute('data-max_ttl', u.max_ttl)
|
||||
if (u.admin) {
|
||||
ip.setAttribute('data-is_admin', "")
|
||||
}
|
||||
if (u.second_factor) {
|
||||
ip.setAttribute('data-second_factor', "")
|
||||
async function fetchLog() {
|
||||
console.debug("fetching log")
|
||||
let response = await window.fetch(`${host}/api/log?last=20`, {credentials: "include"})
|
||||
|
||||
if (!response.ok) {
|
||||
alert("Could not load log")
|
||||
return
|
||||
}
|
||||
|
||||
return userList.append(ip)
|
||||
let json = {}
|
||||
try {
|
||||
json = await response.json()
|
||||
} catch (err) {
|
||||
alert(err)
|
||||
return
|
||||
}
|
||||
|
||||
document.querySelector("#rex-records").replaceChildren(...json.map(rex => {
|
||||
const tr = document.createElement("tr")
|
||||
tr.classList.add("rex-staus-" + (!rex.error ? "ok" : "failure"))
|
||||
|
||||
const status = !rex.error ? "ok" : `<strong>${rex.error}</strong> ${rex.failure}`
|
||||
tr.innerHTML = `<th class="log-record-timestamp">${(new Date(rex.timestamp)).toISOString()}</th>
|
||||
<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>`
|
||||
// tr.appendChild(new REXRow(record))
|
||||
return tr
|
||||
}))
|
||||
}
|
||||
|
||||
async function CreateUser(form) {
|
||||
let user = Object.fromEntries(new FormData(form))
|
||||
delete(user.id)
|
||||
if (user.expires != "") {
|
||||
user.expires = (new Date(user.expires)).toISOString()
|
||||
} else {
|
||||
delete(user.expires)
|
||||
}
|
||||
|
||||
if (user.max_ttl == "") {
|
||||
delete(user.max_ttl)
|
||||
}
|
||||
|
||||
if (user.schedule == "") {
|
||||
delete(user.schedule)
|
||||
}
|
||||
|
||||
|
||||
user.admin = (user.admin == "on")
|
||||
user.second_factor = (user.second_factor == "on")
|
||||
|
||||
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 create user:", response)
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function switchTab() {
|
||||
let tabName = window.location.hash.toLowerCase().replace("#", "")
|
||||
let activate = async () => true
|
||||
console.log(`switching to tab ${tabName}`)
|
||||
switch (tabName) {
|
||||
case "crear":
|
||||
break;
|
||||
case "registro":
|
||||
activate = fetchLog
|
||||
break;
|
||||
case "":
|
||||
tabName = "invitades"
|
||||
case "invitades":
|
||||
activate = fetchUsers
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown tab ${tabName}`)
|
||||
}
|
||||
console.log(`activating tab ${tabName}`)
|
||||
|
||||
let open = document.querySelector(".tab-open")
|
||||
if (open) {
|
||||
open.classList.remove("tab-open")
|
||||
open.classList.add("hidden")
|
||||
}
|
||||
let tab = document.querySelector(`#${tabName}`)
|
||||
tab.classList.add("tab-open")
|
||||
tab.classList.remove("hidden")
|
||||
|
||||
await activate(tab)
|
||||
}
|
||||
|
||||
window.addEventListener("load", async function() {
|
||||
await fetchUsers()
|
||||
const form = document.querySelector("#create-user")
|
||||
form.addEventListener("submit", async (evt) => {
|
||||
evt.preventDefault()
|
||||
await CreateUser(form)
|
||||
})
|
||||
|
||||
switchTab()
|
||||
})
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
switchTab()
|
||||
})
|
||||
|
||||
|
@ -72,3 +72,8 @@ input {
|
||||
max-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.hidden {
|
||||
display: none
|
||||
}
|
||||
|
@ -1,11 +1,15 @@
|
||||
const button = document.querySelector("#open button")
|
||||
const form = document.querySelector("#open")
|
||||
const { create: createCredentials, get: getCredentials } = hankoWebAuthn;
|
||||
import * as webauthn from "./webauthn.js"
|
||||
|
||||
// const host = document.location.protocol + "//" + document.location.host
|
||||
const host = "http://localhost:8081"
|
||||
|
||||
async function RequestToEnter() {
|
||||
console.debug("requesting to enter")
|
||||
let response = await window.fetch(`/api/rex`, {
|
||||
let response = await webauthn.withAuth(`${host}/api/rex`, {
|
||||
method: 'POST',
|
||||
credentials: "include"
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@ -25,80 +29,13 @@ async function RequestToEnter() {
|
||||
json = await response.json()
|
||||
} catch {}
|
||||
|
||||
if (json.webauthn) {
|
||||
try {
|
||||
if (json.webauthn == "register") {
|
||||
await register(json.data)
|
||||
} else if (json.webauthn == "login"){
|
||||
await login(json.data)
|
||||
}
|
||||
} catch(err) {
|
||||
console.error("webauthn failure", err)
|
||||
}
|
||||
} else if (json.status == "ok") {
|
||||
if (json.status == "ok") {
|
||||
console.debug("Door opened")
|
||||
}
|
||||
|
||||
return response.status
|
||||
}
|
||||
|
||||
async function register(data) {
|
||||
console.debug("creating credentials")
|
||||
const credential = await createCredentials(data);
|
||||
|
||||
console.debug(`exchanging credential: ${JSON.stringify(credential)}`)
|
||||
let response = await window.fetch(`/api/rex`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(credential)
|
||||
})
|
||||
|
||||
console.debug("sent credential creation request")
|
||||
|
||||
if (!response.ok) {
|
||||
let message = response.statusText
|
||||
try {
|
||||
let json = await response.json()
|
||||
if (json.message) {
|
||||
message = `${message}: ${json.message}`
|
||||
}
|
||||
} catch {}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function login(data) {
|
||||
console.debug("fetching passkey")
|
||||
const credential = await getCredentials(data);
|
||||
|
||||
console.debug(`exchanging credential: ${JSON.stringify(credential)}`)
|
||||
let response = await window.fetch(`/api/rex`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(credential)
|
||||
})
|
||||
|
||||
console.debug("sent passkey")
|
||||
|
||||
if (!response.ok) {
|
||||
let message = response.statusText
|
||||
try {
|
||||
let json = await response.json()
|
||||
if (json.message) {
|
||||
message = `${message}: ${json.message}`
|
||||
}
|
||||
} catch {}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function clearStatus() {
|
||||
form.classList.remove("failed")
|
||||
|
105
internal/server/static/webauthn.js
Normal file
105
internal/server/static/webauthn.js
Normal file
@ -0,0 +1,105 @@
|
||||
const { create: createCredentials, get: getCredentials } = hankoWebAuthn;
|
||||
|
||||
const charsToEncode = /[\u007f-\uffff]/g;
|
||||
function JSONtob64(data) {
|
||||
return btoa(JSON.stringify(data).replace(charsToEncode, (c) => '\\u'+('000'+c.charCodeAt(0).toString(16)).slice(-4)))
|
||||
}
|
||||
|
||||
function b64ToJSON(encoded) {
|
||||
return JSON.parse(decodeURIComponent(atob(encoded).split('').map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')))
|
||||
}
|
||||
|
||||
export async function withAuth(target, config) {
|
||||
console.log(`webauthn: issuing api request: ${target}`)
|
||||
const response = await window.fetch(target, config)
|
||||
console.debug(`webauthn: issued api request: ${target}`)
|
||||
|
||||
if (!response.ok) {
|
||||
console.debug(`webauthn: failed request to ${target}`)
|
||||
return response
|
||||
}
|
||||
|
||||
console.log(Object.fromEntries(response.headers))
|
||||
const challengeHeader = response.headers.get("webauthn")
|
||||
if (!challengeHeader || challengeHeader == "") {
|
||||
console.debug(`webauthn: success without auth`)
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
let [step, data] = challengeHeader.split(" ")
|
||||
if (step == "") {
|
||||
throw `webauthn: Invalid challenge received from server: ${response.headers.get("webauthn")}`
|
||||
}
|
||||
|
||||
console.info(`webauthn: server issued <${step}> challenge, decoding`)
|
||||
let challenge = b64ToJSON(data)
|
||||
|
||||
if (step == "register") {
|
||||
// server told us to register new credentials
|
||||
// we try to do that
|
||||
await register(challenge, target)
|
||||
// and retry the original request if successful
|
||||
return await withAuth(target, config)
|
||||
} else if (step == "login") {
|
||||
// server told us to use existing credential for request
|
||||
return await login(challenge, target, config)
|
||||
}
|
||||
|
||||
throw `Unknown webauthn step: <${kind}>`
|
||||
}
|
||||
|
||||
async function register(challenge) {
|
||||
console.info("webauthn: creating credentials")
|
||||
const credential = await createCredentials(challenge);
|
||||
|
||||
console.debug(`webauthn: registering credentials with server: ${JSON.stringify(credential)}`)
|
||||
let response = await window.fetch("/api/webauthn/register", {
|
||||
credentials: "include",
|
||||
body: JSON.stringify(credential),
|
||||
headers: {
|
||||
'Content-type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let message = response.statusText
|
||||
try {
|
||||
let json = await response.json()
|
||||
if (json.message) {
|
||||
message = `${message}: ${json.message}`
|
||||
}
|
||||
} catch {}
|
||||
|
||||
throw new Error(`webauthn: failed to register credentials: ${message}`);
|
||||
}
|
||||
|
||||
console.info("webauthn: created credentials")
|
||||
}
|
||||
|
||||
async function login(challenge, target, config) {
|
||||
console.info("webauthn: fetching stored client credentials")
|
||||
const credential = await getCredentials(challenge);
|
||||
|
||||
config.credentials = "include"
|
||||
config.headers = config.headers || {}
|
||||
config.headers.webauthn = JSONtob64(credential)
|
||||
|
||||
console.info(`webauthn: issuing authenticated request to ${target}`)
|
||||
let response = await window.fetch(target, config)
|
||||
|
||||
if (!response.ok) {
|
||||
let message = response.statusText
|
||||
try {
|
||||
let json = await response.json()
|
||||
if (json.message) {
|
||||
message = `${message}: ${json.message}`
|
||||
}
|
||||
} catch {}
|
||||
|
||||
throw new Error(`webauthn: got error from authenticated request: ${message}`);
|
||||
}
|
||||
|
||||
console.info("webauthn: sucessfully sent authenticated request")
|
||||
return response
|
||||
}
|
@ -39,7 +39,6 @@ CREATE TABLE log(
|
||||
second_factor BOOLEAN NOT NULL,
|
||||
failure VARCHAR(255),
|
||||
error TEXT,
|
||||
success boolean NOT NULL,
|
||||
ip_address varchar(255) NOT NULL,
|
||||
user_agent varchar(255) NOT NULL
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user