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
@@ -0,0 +1,180 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/basking/internal/config"
|
||||
"git.veillax.com/Veillax/basking/internal/oidc"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "basking_session"
|
||||
flowCookie = "basking_signin"
|
||||
sessionTTL = 14 * 24 * time.Hour
|
||||
flowTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
// A cookie is "payload.signature": the payload is JSON, the signature is an HMAC with the secret from
|
||||
// the settings file. Nothing is kept on the server, so a restart does not sign anyone out.
|
||||
func seal(c config.Config, kind string, v any) string {
|
||||
raw, _ := json.Marshal(v)
|
||||
payload := base64.RawURLEncoding.EncodeToString(raw)
|
||||
return payload + "." + mac(c, kind, payload)
|
||||
}
|
||||
|
||||
func mac(c config.Config, kind, payload string) string {
|
||||
m := hmac.New(sha256.New, []byte(c.Secret))
|
||||
m.Write([]byte(kind + "|" + payload))
|
||||
return hex.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
func unseal(c config.Config, kind, value string, v any) bool {
|
||||
payload, sig, ok := strings.Cut(value, ".")
|
||||
if !ok || !hmac.Equal([]byte(sig), []byte(mac(c, kind, payload))) {
|
||||
return false
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
return err == nil && json.Unmarshal(raw, v) == nil
|
||||
}
|
||||
|
||||
type sessionData struct {
|
||||
Sub string `json:"s"`
|
||||
Name string `json:"n"`
|
||||
Email string `json:"e"`
|
||||
User string `json:"u"`
|
||||
Exp int64 `json:"x"`
|
||||
}
|
||||
|
||||
type flowData struct {
|
||||
State string `json:"s"`
|
||||
Nonce string `json:"n"`
|
||||
Verifier string `json:"v"`
|
||||
Next string `json:"t"`
|
||||
Exp int64 `json:"x"`
|
||||
}
|
||||
|
||||
// session reads the sign-in cookie. The allowed list is checked on every request, so taking someone
|
||||
// off it takes effect at once.
|
||||
func (s *Server) session(c config.Config, r *http.Request) (sessionData, bool) {
|
||||
var d sessionData
|
||||
ck, err := r.Cookie(sessionCookie)
|
||||
if err != nil || !unseal(c, "session", ck.Value, &d) || d.Sub == "" || time.Now().Unix() > d.Exp {
|
||||
return sessionData{}, false
|
||||
}
|
||||
return d, allowed(c, d)
|
||||
}
|
||||
|
||||
func allowed(c config.Config, d sessionData) bool {
|
||||
if len(c.Allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range c.Allowed {
|
||||
for _, who := range []string{d.Email, d.User, d.Sub} {
|
||||
if who != "" && strings.EqualFold(strings.TrimSpace(a), who) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setCookie(w http.ResponseWriter, c config.Config, name, value, path string, ttl time.Duration) {
|
||||
ck := &http.Cookie{Name: name, Value: value, Path: path, HttpOnly: true, Secure: c.Secure(), SameSite: http.SameSiteLaxMode}
|
||||
if value == "" {
|
||||
ck.MaxAge = -1
|
||||
} else {
|
||||
ck.MaxAge = int(ttl.Seconds())
|
||||
}
|
||||
http.SetCookie(w, ck)
|
||||
}
|
||||
|
||||
// Where to go after signing in: only a place inside Basking, such as #/p/abc.
|
||||
var nextRe = regexp.MustCompile(`^#/[A-Za-z0-9/_.~%-]{0,300}$`)
|
||||
|
||||
func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||
c := s.cfg.Get()
|
||||
next := r.URL.Query().Get("next")
|
||||
if !nextRe.MatchString(next) {
|
||||
next = ""
|
||||
}
|
||||
f := flowData{State: oidc.Random(18), Nonce: oidc.Random(18), Verifier: oidc.Random(40), Next: next, Exp: time.Now().Add(flowTTL).Unix()}
|
||||
to, err := s.oidc.AuthURL(r.Context(), f.State, f.Nonce, oidc.Challenge(f.Verifier))
|
||||
if err != nil {
|
||||
log.Printf("sign-in: %v", err)
|
||||
http.Redirect(w, r, "/?signin=unavailable", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
setCookie(w, c, flowCookie, seal(c, "flow", f), "/auth/", flowTTL)
|
||||
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) authCallback(w http.ResponseWriter, r *http.Request) {
|
||||
c := s.cfg.Get()
|
||||
back := func(why string) { http.Redirect(w, r, "/?signin="+why, http.StatusSeeOther) }
|
||||
|
||||
var f flowData
|
||||
ck, err := r.Cookie(flowCookie)
|
||||
setCookie(w, c, flowCookie, "", "/auth/", 0) // a sign-in attempt is used once
|
||||
if err != nil || !unseal(c, "flow", ck.Value, &f) || time.Now().Unix() > f.Exp {
|
||||
back("expired")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if e := q.Get("error"); e != "" {
|
||||
log.Printf("sign-in: the provider answered %q: %s", e, q.Get("error_description"))
|
||||
back("denied")
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(f.State)) != 1 || q.Get("code") == "" {
|
||||
back("failed")
|
||||
return
|
||||
}
|
||||
id, err := s.oidc.Exchange(r.Context(), q.Get("code"), f.Verifier, f.Nonce)
|
||||
if err != nil {
|
||||
log.Printf("sign-in: %v", err)
|
||||
if errors.Is(err, oidc.ErrUnavailable) {
|
||||
back("unavailable")
|
||||
} else {
|
||||
back("failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
d := sessionData{Sub: id.Sub, Name: firstOf(id.Name, id.Username, id.Email, "You"), Email: id.Email, User: id.Username,
|
||||
Exp: time.Now().Add(sessionTTL).Unix()}
|
||||
if !allowed(c, d) {
|
||||
log.Printf("sign-in: %s (%s) is not on the allowed list", d.Name, firstOf(d.Email, d.User, d.Sub))
|
||||
back("denied")
|
||||
return
|
||||
}
|
||||
setCookie(w, c, sessionCookie, seal(c, "session", d), "/", sessionTTL)
|
||||
http.Redirect(w, r, "/"+f.Next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func firstOf(s ...string) string {
|
||||
for _, x := range s {
|
||||
if strings.TrimSpace(x) != "" {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
setCookie(w, s.cfg.Get(), sessionCookie, "", "/", 0)
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
writeJSON(w, map[string]any{"name": p.Name, "email": p.Email, "version": s.version})
|
||||
}
|
||||
Reference in new issue
Block a user