diff --git a/internal/auth/auth.go b/internal/auth/auth.go index cb5fa95..282d92f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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() -} diff --git a/internal/auth/duration.go b/internal/auth/duration.go index 9325fdf..e7312e6 100644 --- a/internal/auth/duration.go +++ b/internal/auth/duration.go @@ -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{})) diff --git a/internal/auth/errors.go b/internal/auth/errors.go index 112e991..beeb50b 100644 --- a/internal/auth/errors.go +++ b/internal/auth/errors.go @@ -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") } diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..ec8b716 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright © 2022 Roberto Hidalgo +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) + }) +} diff --git a/internal/auth/schedule.go b/internal/auth/schedule.go index d9553f0..fe16069 100644 --- a/internal/auth/schedule.go +++ b/internal/auth/schedule.go @@ -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{})) diff --git a/internal/auth/user.go b/internal/auth/user.go index a6e454c..5ec5de8 100644 --- a/internal/auth/user.go +++ b/internal/auth/user.go @@ -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()) } diff --git a/internal/auth/webauthn.go b/internal/auth/webauthn.go new file mode 100644 index 0000000..b64920b --- /dev/null +++ b/internal/auth/webauthn.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright © 2022 Roberto Hidalgo +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() +} diff --git a/internal/server/admin.go b/internal/server/admin.go index 7e59f49..12ead46 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -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) +} diff --git a/internal/server/admin.html b/internal/server/admin.html index c33d76d..67cf32c 100644 --- a/internal/server/admin.html +++ b/internal/server/admin.html @@ -9,29 +9,111 @@ +

Puerta

-

Admin

+
-
+ + + - - diff --git a/internal/server/index.html b/internal/server/index.html index 5564f8a..500d936 100644 --- a/internal/server/index.html +++ b/internal/server/index.html @@ -9,7 +9,6 @@ -
@@ -23,6 +22,7 @@ - + + diff --git a/internal/server/server.go b/internal/server/server.go index e7b71f3..f993192 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 } diff --git a/internal/server/static/admin.js b/internal/server/static/admin.js index 4bea086..cbf83b5 100644 --- a/internal/server/static/admin.js +++ b/internal/server/static/admin.js @@ -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" : `${rex.error} ${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"}) - return userList.append(ip) + 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" : `${rex.error} ${rex.failure}` + tr.innerHTML = `${(new Date(rex.timestamp)).toISOString()} + ${rex.user} + ${status} + ${rex.second_factor ? "✓" : ""} + ${rex.ip_address} + ${rex.user_agent}` + // 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() +}) + diff --git a/internal/server/static/index.css b/internal/server/static/index.css index 6d11fd4..129c525 100644 --- a/internal/server/static/index.css +++ b/internal/server/static/index.css @@ -72,3 +72,8 @@ input { max-width: auto; } } + + +.hidden { + display: none +} diff --git a/internal/server/static/index.js b/internal/server/static/index.js index c69b4e0..62343ef 100644 --- a/internal/server/static/index.js +++ b/internal/server/static/index.js @@ -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" -async function RequestToEnter() { +// 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") diff --git a/internal/server/static/webauthn.js b/internal/server/static/webauthn.js new file mode 100644 index 0000000..18b3d00 --- /dev/null +++ b/internal/server/static/webauthn.js @@ -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 +} diff --git a/schema.sql b/schema.sql index 5ceee3b..65614fb 100644 --- a/schema.sql +++ b/schema.sql @@ -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 );