moar webauthn and admin stuff

This commit is contained in:
Roberto Hidalgo 2023-01-03 01:40:38 -06:00
parent eee573eb47
commit 63b9a753e8
16 changed files with 772 additions and 423 deletions

View File

@ -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()
}

View File

@ -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{}))

View File

@ -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")
} }

149
internal/auth/middleware.go Normal file
View File

@ -0,0 +1,149 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
package auth
import (
"context"
"fmt"
"net/http"
"github.com/julienschmidt/httprouter"
"github.com/sirupsen/logrus"
"github.com/upper/db/v4"
)
func (am *Manager) withUser(handler httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
ctxUser := req.Context().Value(ContextUser)
req = func() *http.Request {
if ctxUser != nil {
return req
}
cookie, err := req.Cookie(string(ContextCookieName))
if err != nil {
logrus.Debugf("no cookie for user found in jar <%s>", req.Cookies())
return req
}
session := &SessionUser{}
q := am.db.SQL().
Select("s.token as token, ", "u.*").
From("session as s").
Join("user as u").On("s.user = u.id").
Where(db.Cond{"s.token": cookie.Value})
if err := q.One(&session); err != nil {
logrus.Debugf("no cookie found in DB for jar <%s>: %s", req.Cookies(), err)
w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Secure; Path=/;", ContextCookieName, "", -1))
return req
}
if session.Expired() {
logrus.Debugf("expired cookie found in DB for jar <%s>", req.Cookies())
w.Header().Add("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Secure; Path=/;", ContextCookieName, "", -1))
err := am.db.Collection("session").Find(db.Cond{"token": cookie.Value}).Delete()
if err != nil {
logrus.Errorf("could not purge expired session from DB: %s", err)
}
return req
}
return req.WithContext(context.WithValue(req.Context(), ContextUser, &session.User))
}()
handler(w, req, ps)
}
}
func (am *Manager) RequireAuth(handler httprouter.Handle) httprouter.Handle {
return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
if req.Context().Value(ContextUser) == nil {
am.requestAuth(w, http.StatusUnauthorized)
return
}
handler(w, req, ps)
})
}
func (am *Manager) RequireAuthOrRedirect(handler httprouter.Handle, target string) httprouter.Handle {
return am.withUser(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
if req.Context().Value(ContextUser) == nil {
http.Redirect(w, req, target, http.StatusTemporaryRedirect)
return
}
handler(w, req, ps)
})
}
func (am *Manager) RegisterSecondFactor() httprouter.Handle {
return am.RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
user := req.Context().Value(ContextUser).(*User)
if !user.Require2FA {
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
return
}
err := am.WebAuthnFinishRegistration(req)
if err != nil {
logrus.Errorf("Failed during webauthn flow: %s", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
})
}
func (am *Manager) Enforce2FA(handler httprouter.Handle) httprouter.Handle {
return am.RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
user := req.Context().Value(ContextUser).(*User)
if !user.Require2FA {
handler(w, req, ps)
return
}
logrus.Debug("Enforcing 2fa for request")
var err error
err = user.FetchCredentials(am.db)
if err != nil {
logrus.Errorf("Failed fetching credentials: %s", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
if len(user.credentials) == 0 {
err = am.WebAuthnBeginRegistration(req)
} else {
err = am.WebAuthnLogin(req)
}
if err != nil {
if wafc, ok := err.(WebAuthFlowChallenge); ok {
w.WriteHeader(200)
w.Header().Add("content-type", "application/json")
w.Header().Add("webauthn", wafc.Header())
return
}
logrus.Errorf("Failed during webauthn flow: %s", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
defer am.sess.RenewToken(req.Context())
handler(w, req, ps)
})
}
func (am *Manager) RequireAdmin(handler httprouter.Handle) httprouter.Handle {
return am.RequireAuth(func(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
user := req.Context().Value(ContextUser).(*User)
if !user.IsAdmin {
am.requestAuth(w, http.StatusUnauthorized)
return
}
handler(w, req, ps)
})
}

View File

@ -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{}))

View File

@ -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())
} }

121
internal/auth/webauthn.go Normal file
View File

@ -0,0 +1,121 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright © 2022 Roberto Hidalgo <nidito@un.rob.mx>
package auth
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/sirupsen/logrus"
"github.com/upper/db/v4"
)
const SessionNameWANAuth = "wan-auth"
const SessionNameWANRegister = "wan-register"
const HeaderNameWAN = "webauthn"
func (am *Manager) WebAuthnBeginRegistration(req *http.Request) error {
user := UserFromContext(req)
logrus.Infof("Starting webauthn registration for %s", user.Name)
options, sessionData, err := am.wan.BeginRegistration(user)
if err != nil {
err = fmt.Errorf("error starting webauthn: %s", err)
logrus.Error(err)
return err
}
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(&sessionData); err != nil {
return err
}
am.sess.Put(req.Context(), SessionNameWANRegister, b.Bytes())
return WebAuthFlowChallenge{"register", &options}
}
func (am *Manager) WebAuthnFinishRegistration(req *http.Request) error {
user := UserFromContext(req)
sd := am.sess.PopBytes(req.Context(), SessionNameWANRegister)
if sd == nil {
return fmt.Errorf("error finishing webauthn registration: no session found for user")
}
var sessionData webauthn.SessionData
err := json.Unmarshal(sd, &sessionData)
if err != nil {
return err
}
cred, err := am.wan.FinishRegistration(user, sessionData, req)
if err != nil {
return fmt.Errorf("error finishing webauthn registration: %s", err)
}
data, err := json.Marshal(cred)
if err != nil {
return fmt.Errorf("error encoding webauthn credential for storage: %s", err)
}
credential := &Credential{
UserID: user.ID,
Data: string(data),
}
_, err = am.db.Collection("credential").Insert(credential)
return err
}
func (am *Manager) WebAuthnLogin(req *http.Request) error {
user := UserFromContext(req)
sd := am.sess.PopBytes(req.Context(), SessionNameWANAuth)
if sd == nil {
logrus.Infof("Starting webauthn login flow for %s", user.Name)
options, sessionData, err := am.wan.BeginLogin(user)
if err != nil {
return fmt.Errorf("error starting webauthn login: %s", err)
}
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(&sessionData); err != nil {
return fmt.Errorf("could not encode json: %s", err)
}
am.sess.Put(req.Context(), SessionNameWANAuth, b.Bytes())
return WebAuthFlowChallenge{"login", &options}
}
var sessionData webauthn.SessionData
err := json.Unmarshal(sd, &sessionData)
if err != nil {
return err
}
challengeResponse := req.Header.Get(HeaderNameWAN)
if challengeResponse == "" {
return fmt.Errorf("missing webauthn header")
}
challengeBytes, err := base64.StdEncoding.DecodeString(challengeResponse)
if err != nil {
return fmt.Errorf("unparseable webauthn header value")
}
response, err := protocol.ParseCredentialRequestResponseBody(bytes.NewBuffer(challengeBytes))
if err != nil {
return fmt.Errorf("could not parse webauthn request into protocol: %w", err)
}
_, err = am.wan.ValidateLogin(user, sessionData, response)
return err
}
func (am *Manager) Cleanup() error {
return am.db.Collection("session").Find(db.Cond{"Expires": db.Before(time.Now())}).Delete()
}

View File

@ -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)
}

View File

@ -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>

View File

@ -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>

View File

@ -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
} }

View File

@ -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()
})

View File

@ -72,3 +72,8 @@ input {
max-width: auto; max-width: auto;
} }
} }
.hidden {
display: none
}

View File

@ -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")

View File

@ -0,0 +1,105 @@
const { create: createCredentials, get: getCredentials } = hankoWebAuthn;
const charsToEncode = /[\u007f-\uffff]/g;
function JSONtob64(data) {
return btoa(JSON.stringify(data).replace(charsToEncode, (c) => '\\u'+('000'+c.charCodeAt(0).toString(16)).slice(-4)))
}
function b64ToJSON(encoded) {
return JSON.parse(decodeURIComponent(atob(encoded).split('').map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')))
}
export async function withAuth(target, config) {
console.log(`webauthn: issuing api request: ${target}`)
const response = await window.fetch(target, config)
console.debug(`webauthn: issued api request: ${target}`)
if (!response.ok) {
console.debug(`webauthn: failed request to ${target}`)
return response
}
console.log(Object.fromEntries(response.headers))
const challengeHeader = response.headers.get("webauthn")
if (!challengeHeader || challengeHeader == "") {
console.debug(`webauthn: success without auth`)
return response
}
let [step, data] = challengeHeader.split(" ")
if (step == "") {
throw `webauthn: Invalid challenge received from server: ${response.headers.get("webauthn")}`
}
console.info(`webauthn: server issued <${step}> challenge, decoding`)
let challenge = b64ToJSON(data)
if (step == "register") {
// server told us to register new credentials
// we try to do that
await register(challenge, target)
// and retry the original request if successful
return await withAuth(target, config)
} else if (step == "login") {
// server told us to use existing credential for request
return await login(challenge, target, config)
}
throw `Unknown webauthn step: <${kind}>`
}
async function register(challenge) {
console.info("webauthn: creating credentials")
const credential = await createCredentials(challenge);
console.debug(`webauthn: registering credentials with server: ${JSON.stringify(credential)}`)
let response = await window.fetch("/api/webauthn/register", {
credentials: "include",
body: JSON.stringify(credential),
headers: {
'Content-type': 'application/json'
}
})
if (!response.ok) {
let message = response.statusText
try {
let json = await response.json()
if (json.message) {
message = `${message}: ${json.message}`
}
} catch {}
throw new Error(`webauthn: failed to register credentials: ${message}`);
}
console.info("webauthn: created credentials")
}
async function login(challenge, target, config) {
console.info("webauthn: fetching stored client credentials")
const credential = await getCredentials(challenge);
config.credentials = "include"
config.headers = config.headers || {}
config.headers.webauthn = JSONtob64(credential)
console.info(`webauthn: issuing authenticated request to ${target}`)
let response = await window.fetch(target, config)
if (!response.ok) {
let message = response.statusText
try {
let json = await response.json()
if (json.message) {
message = `${message}: ${json.message}`
}
} catch {}
throw new Error(`webauthn: got error from authenticated request: ${message}`);
}
console.info("webauthn: sucessfully sent authenticated request")
return response
}

View File

@ -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
); );