Basking: sign in with Authentik only, pages that nest and link
Sign-in is now OpenID Connect against Authentik (authorization code flow with PKCE, state and nonce). The password, API tokens and the add command are gone; each person who signs in gets a private notebook in a folder named from a hash of who they are. An optional allowed list is checked on every request. Notes become pages with titles, a tree of subpages, [[links]] with backlinks that follow renames, a Ctrl+K finder, slash commands, list continuation, and Write and Read modes. Autosave keeps the text through a dropped connection or an expired session. Export opens as an Obsidian vault. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b548faa258
commit
98f7730913
19 files changed
+3291
-1578
No files matched your search
+75
-160
@@ -2,62 +2,79 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/basking/internal/config"
|
||||
"git.veillax.com/Veillax/basking/internal/oidc"
|
||||
"git.veillax.com/Veillax/basking/internal/store"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
const (
|
||||
cookieName = "basking_session"
|
||||
sessionTTL = 30 * 24 * time.Hour
|
||||
maxLoginFails = 6
|
||||
loginFailSpan = 5 * time.Minute
|
||||
maxRequestBody = store.MaxBody + 4096
|
||||
)
|
||||
const maxRequestBody = store.MaxBody + 4096
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Holder
|
||||
st *store.Store
|
||||
oidc *oidc.Client
|
||||
version string
|
||||
|
||||
mu sync.Mutex
|
||||
fails map[string]failure
|
||||
|
||||
dummyOnce sync.Once
|
||||
dummyHash string
|
||||
books map[string]*store.Store
|
||||
}
|
||||
|
||||
type failure struct {
|
||||
n int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
// principal is who is making a request.
|
||||
// principal is who is making a request, with the notebook that is theirs alone.
|
||||
type principal struct {
|
||||
Name string
|
||||
API bool // came with an API token rather than a browser session
|
||||
Sub, Name, Email string
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
func New(h *config.Holder, st *store.Store, version string) *Server {
|
||||
return &Server{cfg: h, st: st, version: version, fails: map[string]failure{}}
|
||||
// New checks that Authentik is set up and returns the server.
|
||||
func New(h *config.Holder, version string) (*Server, error) {
|
||||
c := h.Get()
|
||||
if err := c.Check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{cfg: h, version: version, books: map[string]*store.Store{},
|
||||
oidc: oidc.New(c.OIDC.Issuer, c.OIDC.ClientID, c.OIDC.ClientSecret, c.RedirectURL())}, nil
|
||||
}
|
||||
|
||||
// OIDC is the sign-in client, for the check command.
|
||||
func (s *Server) OIDC() *oidc.Client { return s.oidc }
|
||||
|
||||
// notebook opens the notes that belong to one person. Their folder is named from a hash of who they
|
||||
// are, so nothing a person controls ever becomes part of a path.
|
||||
func (s *Server) notebook(c config.Config, sub, name, email string) (*store.Store, error) {
|
||||
sum := sha256.Sum256([]byte(c.OIDC.Issuer + "|" + sub))
|
||||
key := hex.EncodeToString(sum[:16])
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if st, ok := s.books[key]; ok {
|
||||
return st, nil
|
||||
}
|
||||
dir := filepath.Join(c.DataDir, "users", key)
|
||||
st, err := store.Open(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A note for whoever looks after the server, so a folder can be matched to a person.
|
||||
who := fmt.Sprintf("%s <%s>\n", name, email)
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, "owner.txt")); string(b) != who {
|
||||
os.WriteFile(filepath.Join(dir, "owner.txt"), []byte(who), 0o644)
|
||||
}
|
||||
s.books[key] = st
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Handler returns the whole site.
|
||||
@@ -65,10 +82,11 @@ func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
|
||||
|
||||
mux.HandleFunc("POST /api/login", s.login)
|
||||
mux.HandleFunc("GET /auth/login", s.authLogin)
|
||||
mux.HandleFunc("GET /auth/callback", s.authCallback)
|
||||
mux.HandleFunc("POST /api/logout", s.api(s.logout))
|
||||
mux.HandleFunc("GET /api/me", s.api(s.me))
|
||||
mux.HandleFunc("GET /api/summary", s.api(s.summary))
|
||||
mux.HandleFunc("GET /api/pages", s.api(s.pages))
|
||||
|
||||
mux.HandleFunc("GET /api/notes", s.api(s.listNotes))
|
||||
mux.HandleFunc("POST /api/notes", s.api(s.createNote))
|
||||
@@ -78,168 +96,60 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/notes/{id}/restore", s.api(s.restoreNote))
|
||||
mux.HandleFunc("DELETE /api/trash", s.api(s.emptyTrash))
|
||||
mux.HandleFunc("POST /api/daily", s.api(s.daily))
|
||||
mux.HandleFunc("GET /api/find", s.api(s.find))
|
||||
mux.HandleFunc("GET /api/export", s.api(s.export))
|
||||
|
||||
mux.HandleFunc("POST /api/password", s.api(s.changePassword))
|
||||
mux.HandleFunc("GET /api/tokens", s.api(s.listTokens))
|
||||
mux.HandleFunc("POST /api/tokens", s.api(s.newToken))
|
||||
mux.HandleFunc("DELETE /api/tokens/{name}", s.api(s.deleteToken))
|
||||
|
||||
static, _ := fs.Sub(staticFiles, "static")
|
||||
mux.Handle("GET /", http.FileServerFS(static))
|
||||
return secure(mux)
|
||||
return s.secure(mux)
|
||||
}
|
||||
|
||||
// secure adds headers that keep the page from being framed, sniffed or fed outside scripts.
|
||||
func secure(next http.Handler) http.Handler {
|
||||
func (s *Server) secure(next http.Handler) http.Handler {
|
||||
hsts := s.cfg.Get().Secure()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
if hsts {
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/auth/") {
|
||||
h.Set("Cache-Control", "no-store")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- authentication ----
|
||||
|
||||
type handlerFunc func(w http.ResponseWriter, r *http.Request, p principal)
|
||||
|
||||
// api wraps a handler with sign-in and CSRF protection.
|
||||
func (s *Server) api(fn handlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := s.authenticate(r)
|
||||
c := s.cfg.Get()
|
||||
ses, ok := s.session(c, r)
|
||||
if !ok {
|
||||
fail(w, http.StatusUnauthorized, "sign in required")
|
||||
return
|
||||
}
|
||||
// Browser sessions must send a header a web page on another site cannot add.
|
||||
if !p.API && r.Method != http.MethodGet && r.Header.Get("X-Basking") != "1" {
|
||||
// A browser must send a header a web page on another site cannot add.
|
||||
if r.Method != http.MethodGet && r.Header.Get("X-Basking") != "1" {
|
||||
fail(w, http.StatusForbidden, "missing X-Basking header")
|
||||
return
|
||||
}
|
||||
st, err := s.notebook(c, ses.Sub, ses.Name, ses.Email)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "could not open your notes: "+err.Error())
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
||||
fn(w, r, p)
|
||||
fn(w, r, principal{Sub: ses.Sub, Name: ses.Name, Email: ses.Email, st: st})
|
||||
}
|
||||
}
|
||||
|
||||
// A session cookie is "expiry.signature". The signature covers the password hash, so changing
|
||||
// the password signs every browser out, and nothing has to be remembered on the server.
|
||||
func sign(c config.Config, exp int64) string {
|
||||
m := hmac.New(sha256.New, []byte(c.Secret))
|
||||
fmt.Fprintf(m, "session|%d|%s", exp, c.PasswordHash)
|
||||
return hex.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(r *http.Request) (principal, bool) {
|
||||
c := s.cfg.Get()
|
||||
if c.PasswordHash == "" {
|
||||
return principal{}, false
|
||||
}
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
sum := config.HashToken(strings.TrimPrefix(h, "Bearer "))
|
||||
for _, t := range c.Tokens {
|
||||
if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(sum)) == 1 {
|
||||
return principal{Name: "token:" + t.Name, API: true}, true
|
||||
}
|
||||
}
|
||||
return principal{}, false
|
||||
}
|
||||
ck, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return principal{}, false
|
||||
}
|
||||
expStr, sig, ok := strings.Cut(ck.Value, ".")
|
||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||
if !ok || err != nil || time.Now().Unix() > exp {
|
||||
return principal{}, false
|
||||
}
|
||||
if !hmac.Equal([]byte(sig), []byte(sign(c, exp))) {
|
||||
return principal{}, false
|
||||
}
|
||||
return principal{Name: "you"}, true
|
||||
}
|
||||
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
if s.cfg.Get().TrustProxy {
|
||||
if f := r.Header.Get("X-Forwarded-For"); f != "" {
|
||||
return strings.TrimSpace(strings.Split(f, ",")[0])
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) secureCookie(r *http.Request) bool {
|
||||
c := s.cfg.Get()
|
||||
return c.CookieSecure || r.TLS != nil || (c.TrustProxy && r.Header.Get("X-Forwarded-Proto") == "https")
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request) {
|
||||
c := s.cfg.Get()
|
||||
exp := time.Now().Add(sessionTTL).Unix()
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: fmt.Sprintf("%d.%s", exp, sign(c, exp)), Path: "/", HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode, Secure: s.secureCookie(r), MaxAge: int(sessionTTL.Seconds())})
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if r.Header.Get("X-Basking") != "1" {
|
||||
fail(w, http.StatusForbidden, "missing X-Basking header")
|
||||
return
|
||||
}
|
||||
var in struct{ Password string }
|
||||
if json.NewDecoder(r.Body).Decode(&in) != nil {
|
||||
fail(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
s.mu.Lock()
|
||||
f := s.fails[ip]
|
||||
if f.n >= maxLoginFails && time.Now().Before(f.until) {
|
||||
s.mu.Unlock()
|
||||
fail(w, http.StatusTooManyRequests, "too many failed attempts; wait a few minutes")
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
hash := s.cfg.Get().PasswordHash
|
||||
if hash == "" {
|
||||
// Spend the same time as for a real check.
|
||||
s.dummyOnce.Do(func() { s.dummyHash, _ = config.HashPassword("dummy") })
|
||||
hash = s.dummyHash
|
||||
}
|
||||
ok := config.CheckPassword(hash, in.Password) && s.cfg.Get().PasswordHash != ""
|
||||
s.mu.Lock()
|
||||
if !ok {
|
||||
if time.Now().After(f.until) {
|
||||
f = failure{}
|
||||
}
|
||||
f.n++
|
||||
f.until = time.Now().Add(loginFailSpan)
|
||||
s.fails[ip] = f
|
||||
s.mu.Unlock()
|
||||
fail(w, http.StatusUnauthorized, "wrong password")
|
||||
return
|
||||
}
|
||||
delete(s.fails, ip)
|
||||
s.mu.Unlock()
|
||||
s.startSession(w, r)
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) { writeJSONStatus(w, http.StatusOK, v) }
|
||||
@@ -252,12 +162,14 @@ func writeJSONStatus(w http.ResponseWriter, code int, v any) {
|
||||
|
||||
// fail answers with an error. Messages are capitalized here, once, so they read as sentences.
|
||||
func fail(w http.ResponseWriter, code int, msg string) {
|
||||
failCode(w, code, msg, "")
|
||||
}
|
||||
|
||||
func failCode(w http.ResponseWriter, code int, msg, kind string) {
|
||||
if r := []rune(msg); len(r) > 0 {
|
||||
msg = strings.ToUpper(string(r[:1])) + string(r[1:])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
writeJSONStatus(w, code, map[string]string{"error": msg, "code": kind})
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
@@ -280,7 +192,10 @@ func storeFail(w http.ResponseWriter, err error) {
|
||||
fail(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, store.ErrConflict):
|
||||
fail(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, store.ErrEmpty), errors.Is(err, store.ErrBadDate), errors.Is(err, store.ErrNotTrash), errors.Is(err, store.ErrBadID):
|
||||
case errors.Is(err, store.ErrTitleTaken):
|
||||
failCode(w, http.StatusUnprocessableEntity, err.Error(), "title_taken")
|
||||
case errors.Is(err, store.ErrEmpty), errors.Is(err, store.ErrBadDate), errors.Is(err, store.ErrNotTrash), errors.Is(err, store.ErrBadID),
|
||||
errors.Is(err, store.ErrDailyTitle), errors.Is(err, store.ErrBadParent):
|
||||
fail(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, store.ErrTooLong):
|
||||
fail(w, http.StatusRequestEntityTooLarge, err.Error())
|
||||
|
||||
Reference in new issue
Block a user