moar webauthn and admin stuff
This commit is contained in:
parent
eee573eb47
commit
63b9a753e8
|
@ -3,9 +3,6 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
@ -94,202 +91,3 @@ func (am *Manager) NewSession(w http.ResponseWriter, req *http.Request, ps httpr
|
||||||
http.Redirect(w, req, "/", http.StatusSeeOther)
|
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
|
type TTL string
|
||||||
|
|
||||||
func (ttl TTL) ToDuration() (res time.Duration, err error) {
|
func (ttl TTL) ToDuration() (res time.Duration, err error) {
|
||||||
|
if ttl == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
suffix := ttl[len(ttl)-1]
|
suffix := ttl[len(ttl)-1]
|
||||||
|
|
||||||
toParse := string(ttl)
|
toParse := string(ttl)
|
||||||
|
@ -52,6 +55,3 @@ func (ttl *TTL) Seconds() int {
|
||||||
d, _ := ttl.ToDuration()
|
d, _ := ttl.ToDuration()
|
||||||
return int(d.Seconds())
|
return int(d.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
// var _ = (db.Unmarshaler(&TTL{}))
|
|
||||||
// var _ = (db.Marshaler(&TTL{}))
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
@ -47,6 +48,17 @@ func (c WebAuthFlowChallenge) Error() string {
|
||||||
return string(b)
|
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() {
|
func (c WebAuthFlowChallenge) Log() {
|
||||||
logrus.Error("responding with webauthn challenge")
|
logrus.Error("responding with webauthn challenge")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
return json.Marshal(d.src)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *UserSchedule) UnmarshalDB(b any) error {
|
func (d *UserSchedule) Parse() error {
|
||||||
var v string
|
for _, kv := range strings.Split(d.src, " ") {
|
||||||
if err := json.Unmarshal(b.([]byte), &v); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
*d = UserSchedule{src: v}
|
|
||||||
for _, kv := range strings.Split(v, " ") {
|
|
||||||
kvSlice := strings.Split(kv, "=")
|
kvSlice := strings.Split(kv, "=")
|
||||||
key := kvSlice[0]
|
key := kvSlice[0]
|
||||||
values := strings.Split(kvSlice[1], "-")
|
values := strings.Split(kvSlice[1], "-")
|
||||||
|
@ -85,7 +79,31 @@ func (d *UserSchedule) UnmarshalDB(b any) error {
|
||||||
d.hours = []float64{from, until}
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,3 +130,5 @@ func (sch *UserSchedule) AllowedAt(t time.Time) bool {
|
||||||
|
|
||||||
var _ = (db.Unmarshaler(&UserSchedule{}))
|
var _ = (db.Unmarshaler(&UserSchedule{}))
|
||||||
var _ = (db.Marshaler(&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 {
|
type User struct {
|
||||||
ID int `db:"id" json:"-"`
|
ID int `db:"id,omitempty" 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"`
|
|
||||||
Expires *time.Time `db:"expires,omitempty" json:"expires"`
|
Expires *time.Time `db:"expires,omitempty" json:"expires"`
|
||||||
Greeting string `db:"greeting" json:"greeting"`
|
Greeting string `db:"greeting" json:"greeting"`
|
||||||
TTL *TTL `db:"max_ttl,omitempty" json:"max_ttl"`
|
Handle string `db:"handle" json:"handle"`
|
||||||
Require2FA bool `db:"second_factor" json:"second_factor"`
|
|
||||||
IsAdmin bool `db:"is_admin" json:"is_admin"`
|
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
|
credentials []*Credential
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,6 +110,14 @@ func (o *User) UnmarshalJSON(b []byte) error {
|
||||||
return nil
|
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 {
|
func (user *User) Expired() bool {
|
||||||
return user.Expires != nil && user.Expires.Before(time.Now())
|
return user.Expires != nil && user.Expires.Before(time.Now())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.rob.mx/nidito/puerta/internal/auth"
|
"git.rob.mx/nidito/puerta/internal/auth"
|
||||||
"github.com/julienschmidt/httprouter"
|
"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) {
|
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 {
|
if user == nil {
|
||||||
user = &auth.User{}
|
user = &auth.User{
|
||||||
|
Handle: res.Handle,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isAdmin, err := strconv.ParseBool(r.FormValue("is_admin"))
|
user.Name = res.Name
|
||||||
if err != nil {
|
user.Expires = res.Expires
|
||||||
return nil, err
|
user.Greeting = res.Greeting
|
||||||
}
|
user.IsAdmin = res.IsAdmin
|
||||||
secondFactor, err := strconv.ParseBool(r.FormValue("second_factor"))
|
user.Require2FA = res.Require2FA
|
||||||
if err != nil {
|
user.Schedule = res.Schedule
|
||||||
return nil, err
|
user.TTL = res.TTL
|
||||||
}
|
|
||||||
|
|
||||||
user.Handle = r.FormValue("handle")
|
if res.Password != "" {
|
||||||
user.Name = r.FormValue("name")
|
password, err := bcrypt.GenerateFromPassword([]byte(res.Password), bcrypt.DefaultCost)
|
||||||
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 err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
user.Password = string(password)
|
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
|
return user, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -153,3 +131,14 @@ func deleteUser(w http.ResponseWriter, r *http.Request, params httprouter.Params
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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/css/fonts.css" />
|
||||||
<link rel="stylesheet" href="https://cdn.rob.mx/nidito/index.css" />
|
<link rel="stylesheet" href="https://cdn.rob.mx/nidito/index.css" />
|
||||||
<link rel="stylesheet" href="/static/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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="main-header">
|
<header id="main-header">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Puerta</h1>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<section id="create-user" style="display:none">
|
<section id="invitades" class="hidden">
|
||||||
<h2>Crear usuario</h2>
|
<h2>Invitades</h2>
|
||||||
<form id="create-user" method="post" action="/api/user">
|
<ul id="user-list"></ul>
|
||||||
<label for="user">Handle</label>
|
<template id="user-info-panel">
|
||||||
<input name="handle" placeholder="joao" autocorrect="off"/>
|
<style>
|
||||||
|
@import "/static/index.css";
|
||||||
|
|
||||||
<label for="name">Nombre</label>
|
.user-info-panel {
|
||||||
<input name="name" placeholder="João Gilberto" />
|
list-style: none;
|
||||||
|
|
||||||
<label for="greeting">Greeting</label>
|
}
|
||||||
<input name="greeting" placeholder="Olá Joãzinho!" />
|
</style>
|
||||||
|
<li class="user-info-panel">
|
||||||
<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">
|
|
||||||
<header>
|
<header>
|
||||||
<h3><slot name="name">Alguien</h3>
|
<h3>Alguien</h3>
|
||||||
<code><pre>alguien</pre></code>
|
<code><pre>alguien</pre></code>
|
||||||
<button class="user-edit">Modificar</button>
|
<button class="user-edit">Modificar</button>
|
||||||
</header>
|
</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>
|
<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>
|
<label for="greeting">Greeting</label>
|
||||||
<input name="greeting" placeholder="Olá Joãzinho!" />
|
<input name="greeting" placeholder="Olá Joãzinho!" />
|
||||||
|
@ -95,6 +96,71 @@
|
||||||
<button class="user-delete">Eliminar</button>
|
<button class="user-delete">Eliminar</button>
|
||||||
<button class="user-save">Guardar cambios</button>
|
<button class="user-save">Guardar cambios</button>
|
||||||
</form>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -9,7 +9,6 @@
|
||||||
<link rel="stylesheet" href="https://cdn.rob.mx/css/fonts.css" />
|
<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="https://cdn.rob.mx/nidito/index.css" />
|
||||||
<link rel="stylesheet" href="/static/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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="main-header">
|
<header id="main-header">
|
||||||
|
@ -23,6 +22,7 @@
|
||||||
<button id="rex">Abrir</button>
|
<button id="rex">Abrir</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -56,14 +56,14 @@ func ConfigDefaults(dbPath string) *Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
type auditLog struct {
|
type auditLog struct {
|
||||||
Timestamp time.Time `db:"timestamp"`
|
Timestamp string `db:"timestamp" json:"timestamp"`
|
||||||
User string `db:"user"`
|
User string `db:"user" json:"user"`
|
||||||
SecondFactor bool `db:"second_factor"`
|
SecondFactor bool `db:"second_factor" json:"second_factor"`
|
||||||
Failure string `db:"failure"`
|
Failure string `db:"failure" json:"failure"`
|
||||||
Err string `db:"error"`
|
Err string `db:"error" json:"error"`
|
||||||
Success bool `db:"success"`
|
Success bool `db:"success" json:"success"`
|
||||||
IpAddress string `db:"ip_address"`
|
IpAddress string `db:"ip_address" json:"ip_address"`
|
||||||
UserAgent string `db:"user_agent"`
|
UserAgent string `db:"user_agent" json:"user_agent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAuditLog(r *http.Request, err error) *auditLog {
|
func newAuditLog(r *http.Request, err error) *auditLog {
|
||||||
|
@ -72,7 +72,7 @@ func newAuditLog(r *http.Request, err error) *auditLog {
|
||||||
ua := r.Header.Get("user-agent")
|
ua := r.Header.Get("user-agent")
|
||||||
|
|
||||||
al := &auditLog{
|
al := &auditLog{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
User: user.Handle,
|
User: user.Handle,
|
||||||
SecondFactor: user.Require2FA,
|
SecondFactor: user.Require2FA,
|
||||||
IpAddress: ip,
|
IpAddress: ip,
|
||||||
|
@ -90,12 +90,37 @@ func newAuditLog(r *http.Request, err error) *auditLog {
|
||||||
return al
|
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) {
|
func CORS(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get("Access-Control-Request-Method") != "" {
|
if r.Header.Get("Access-Control-Request-Method") != "" {
|
||||||
// Set CORS headers
|
// Set CORS headers
|
||||||
header := w.Header()
|
header := w.Header()
|
||||||
header.Set("Access-Control-Allow-Methods", r.Header.Get("Allow"))
|
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
|
// Adjust status code to 204
|
||||||
|
@ -159,7 +184,7 @@ func Initialize(config *Config) (http.Handler, error) {
|
||||||
wan, err := webauthn.New(&webauthn.Config{
|
wan, err := webauthn.New(&webauthn.Config{
|
||||||
RPDisplayName: config.Name,
|
RPDisplayName: config.Name,
|
||||||
RPID: config.HTTP.Domain,
|
RPID: config.HTTP.Domain,
|
||||||
RPOrigins: []string{uri},
|
RPOrigins: []string{uri, fmt.Sprintf("http://%s:%d", config.HTTP.Domain, 8080)},
|
||||||
// RPIcon: "https://go-webauthn.local/logo.png",
|
// RPIcon: "https://go-webauthn.local/logo.png",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -177,13 +202,15 @@ func Initialize(config *Config) (http.Handler, error) {
|
||||||
router.GET("/login", renderTemplate(loginTemplate))
|
router.GET("/login", renderTemplate(loginTemplate))
|
||||||
router.GET("/", am.RequireAuthOrRedirect(renderTemplate(indexTemplate), "/login"))
|
router.GET("/", am.RequireAuthOrRedirect(renderTemplate(indexTemplate), "/login"))
|
||||||
router.POST("/api/login", am.NewSession)
|
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("/admin", am.RequireAdmin(renderTemplate(adminTemplate)))
|
||||||
router.GET("/api/user", am.RequireAdmin(listUsers))
|
router.GET("/api/log", allowCORS(am.RequireAdmin(rexRecords)))
|
||||||
router.GET("/api/user/:id", am.RequireAdmin(getUser))
|
router.GET("/api/user", allowCORS(am.RequireAdmin(listUsers)))
|
||||||
router.PUT("/api/user", am.RequireAdmin(am.Enforce2FA(createUser)))
|
router.GET("/api/user/:id", allowCORS(am.RequireAdmin(getUser)))
|
||||||
router.POST("/api/user/:id", am.RequireAdmin(am.Enforce2FA(updateUser)))
|
router.POST("/api/user", allowCORS(am.RequireAdmin(am.Enforce2FA(createUser))))
|
||||||
router.DELETE("/api/user/:id", am.RequireAdmin(am.Enforce2FA(deleteUser)))
|
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
|
return am.Route(router), nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
const button = document.querySelector("#open button")
|
import * as webauthn from "./webauthn.js"
|
||||||
const form = document.querySelector("#open")
|
|
||||||
|
|
||||||
const userList = document.querySelector("#user-list > ul")
|
// const host = document.location.protocol + "//" + document.location.host
|
||||||
|
const host = "http://localhost:8081"
|
||||||
|
|
||||||
class UserInfoPanel extends HTMLElement {
|
class UserInfoPanel extends HTMLElement {
|
||||||
constructor(user) {
|
constructor(user) {
|
||||||
|
@ -26,15 +26,30 @@ class UserInfoPanel extends HTMLElement {
|
||||||
shadowRoot.appendChild(panel)
|
shadowRoot.appendChild(panel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
customElements.define("user-info-panel", UserInfoPanel)
|
||||||
|
|
||||||
customElements.define(
|
class REXRow extends HTMLElement {
|
||||||
"user-info-panel",
|
constructor(rex) {
|
||||||
UserInfoPanel
|
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() {
|
async function fetchUsers() {
|
||||||
console.debug("fetching users")
|
console.debug("fetching users")
|
||||||
let response = await window.fetch("/api/user")
|
let response = await window.fetch(`${host}/api/user`, {credentials: "include"})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
alert("Could not load users")
|
alert("Could not load users")
|
||||||
|
@ -49,26 +64,124 @@ async function fetchUsers() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
json.forEach(u => {
|
document.querySelector("#user-list").replaceChildren(...json.map(u => new UserInfoPanel(u)))
|
||||||
const ip = new UserInfoPanel(u)
|
|
||||||
ip.setAttribute("data-name", u.name)
|
|
||||||
ip.setAttribute("data-handle", u.handle)
|
|
||||||
|
|
||||||
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', "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return userList.append(ip)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
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;
|
max-width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
const button = document.querySelector("#open button")
|
const button = document.querySelector("#open button")
|
||||||
const form = document.querySelector("#open")
|
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() {
|
async function RequestToEnter() {
|
||||||
console.debug("requesting to enter")
|
console.debug("requesting to enter")
|
||||||
let response = await window.fetch(`/api/rex`, {
|
let response = await webauthn.withAuth(`${host}/api/rex`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: "include"
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
@ -25,80 +29,13 @@ async function RequestToEnter() {
|
||||||
json = await response.json()
|
json = await response.json()
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
if (json.webauthn) {
|
if (json.status == "ok") {
|
||||||
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") {
|
|
||||||
console.debug("Door opened")
|
console.debug("Door opened")
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.status
|
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() {
|
function clearStatus() {
|
||||||
form.classList.remove("failed")
|
form.classList.remove("failed")
|
||||||
|
|
|
@ -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,
|
second_factor BOOLEAN NOT NULL,
|
||||||
failure VARCHAR(255),
|
failure VARCHAR(255),
|
||||||
error TEXT,
|
error TEXT,
|
||||||
success boolean NOT NULL,
|
|
||||||
ip_address varchar(255) NOT NULL,
|
ip_address varchar(255) NOT NULL,
|
||||||
user_agent varchar(255) NOT NULL
|
user_agent varchar(255) NOT NULL
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in New Issue