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
+111
-130
@@ -1,49 +1,44 @@
|
||||
// Package config holds Basking's settings file: where to listen, where notes live, the password
|
||||
// hash and the API tokens. The notes themselves are not in here.
|
||||
// Package config holds Basking's settings: where to listen, where notes live and how to reach
|
||||
// Authentik. The notes themselves are not in here.
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
Created time.Time `json:"created"`
|
||||
// OIDC is the OAuth client Authentik gives you for Basking.
|
||||
type OIDC struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Listen string `json:"listen"`
|
||||
DataDir string `json:"data_dir"`
|
||||
|
||||
// PasswordHash is the one password that opens the dashboard.
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
// PublicURL is the address you open Basking at, such as https://notes.example.com. The sign-in
|
||||
// redirect is PublicURL + /auth/callback.
|
||||
PublicURL string `json:"public_url"`
|
||||
OIDC OIDC `json:"oidc"`
|
||||
|
||||
// Allowed limits who may sign in, by email, username or Authentik id. Empty means anyone Authentik
|
||||
// lets into the application, each with a notebook of their own.
|
||||
Allowed []string `json:"allowed,omitempty"`
|
||||
|
||||
// Secret signs session cookies, so a restart does not sign you out. It is made on first run.
|
||||
Secret string `json:"secret,omitempty"`
|
||||
Tokens []Token `json:"tokens,omitempty"`
|
||||
|
||||
// CookieSecure and TrustProxy are for running behind an HTTPS reverse proxy.
|
||||
CookieSecure bool `json:"cookie_secure,omitempty"`
|
||||
TrustProxy bool `json:"trust_proxy,omitempty"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
}
|
||||
|
||||
var tokenName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}$`)
|
||||
|
||||
// Defaults fills in what is missing.
|
||||
func (c *Config) Defaults() {
|
||||
if c.Listen == "" {
|
||||
@@ -57,27 +52,75 @@ func (c *Config) Defaults() {
|
||||
rand.Read(b)
|
||||
c.Secret = base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
c.PublicURL = strings.TrimRight(strings.TrimSpace(c.PublicURL), "/")
|
||||
c.OIDC.Issuer = strings.TrimSpace(c.OIDC.Issuer)
|
||||
c.OIDC.ClientID = strings.TrimSpace(c.OIDC.ClientID)
|
||||
c.OIDC.ClientSecret = strings.TrimSpace(c.OIDC.ClientSecret)
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
seen := map[string]bool{}
|
||||
for _, t := range c.Tokens {
|
||||
if !tokenName.MatchString(t.Name) {
|
||||
return fmt.Errorf("token name %q must be 1 to 40 letters, numbers, spaces, dots, dashes or underscores", t.Name)
|
||||
}
|
||||
if seen[strings.ToLower(t.Name)] {
|
||||
return fmt.Errorf("there is already a token called %q", t.Name)
|
||||
}
|
||||
seen[strings.ToLower(t.Name)] = true
|
||||
// RedirectURL is the address to enter in Authentik as the redirect URI.
|
||||
func (c Config) RedirectURL() string { return c.PublicURL + "/auth/callback" }
|
||||
|
||||
// Secure says whether Basking is reached over HTTPS, which decides whether cookies are Secure.
|
||||
func (c Config) Secure() bool { return strings.HasPrefix(c.PublicURL, "https://") }
|
||||
|
||||
// IsLocal reports whether a URL points at this machine, where plain http is fine.
|
||||
func IsLocal(u *url.URL) bool {
|
||||
h := u.Hostname()
|
||||
return h == "localhost" || h == "127.0.0.1" || h == "::1" || strings.HasSuffix(h, ".localhost")
|
||||
}
|
||||
|
||||
func checkURL(name, raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
|
||||
return fmt.Errorf("%s must be a full address such as https://notes.example.com (got %q)", name, raw)
|
||||
}
|
||||
if u.Scheme == "http" && !IsLocal(u) {
|
||||
return fmt.Errorf("%s must use https unless it points at this machine (got %q)", name, raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Holder keeps the live configuration and writes every change to disk.
|
||||
// Check says what is still missing before Basking can sign anyone in, in words a person can act on.
|
||||
func (c Config) Check() error {
|
||||
var missing []string
|
||||
if c.PublicURL == "" {
|
||||
missing = append(missing, " public_url (BASKING_PUBLIC_URL) where you open Basking, such as https://notes.example.com")
|
||||
}
|
||||
if c.OIDC.Issuer == "" {
|
||||
missing = append(missing, " oidc.issuer (BASKING_OIDC_ISSUER) from Authentik, such as https://auth.example.com/application/o/basking/")
|
||||
}
|
||||
if c.OIDC.ClientID == "" {
|
||||
missing = append(missing, " oidc.client_id (BASKING_OIDC_CLIENT_ID) the Client ID of the Authentik provider")
|
||||
}
|
||||
if c.OIDC.ClientSecret == "" {
|
||||
missing = append(missing, " oidc.client_secret (BASKING_OIDC_CLIENT_SECRET) the Client Secret of the Authentik provider")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("Basking signs in with Authentik, and these settings are still empty:\n%s\n\nIn Authentik, make an OAuth2/OpenID provider with the redirect URI %s/auth/callback.\nSee the README for the steps", strings.Join(missing, "\n"), orPlaceholder(c.PublicURL))
|
||||
}
|
||||
if err := checkURL("public_url", c.PublicURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if u, _ := url.Parse(c.PublicURL); u.Path != "" {
|
||||
return fmt.Errorf("public_url must not have a path (got %q)", c.PublicURL)
|
||||
}
|
||||
return checkURL("oidc.issuer", c.OIDC.Issuer)
|
||||
}
|
||||
|
||||
func orPlaceholder(s string) string {
|
||||
if s == "" {
|
||||
return "https://your-address"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Holder keeps the settings file. Environment variables override what is in the file, but are
|
||||
// never written back to it.
|
||||
type Holder struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
cfg Config
|
||||
file Config
|
||||
}
|
||||
|
||||
// Load reads the file. A missing file gives the defaults (and is created by the first Update).
|
||||
@@ -89,14 +132,11 @@ func Load(path string) (*Holder, error) {
|
||||
case err != nil:
|
||||
return nil, err
|
||||
default:
|
||||
if err := json.Unmarshal(b, &h.cfg); err != nil {
|
||||
if err := json.Unmarshal(b, &h.file); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
}
|
||||
h.cfg.Defaults()
|
||||
if err := h.cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
h.file.Defaults()
|
||||
return h, nil
|
||||
}
|
||||
|
||||
@@ -105,108 +145,49 @@ func (h *Holder) Path() string { return h.path }
|
||||
// Exists says whether the file is on disk yet.
|
||||
func (h *Holder) Exists() bool { _, err := os.Stat(h.path); return err == nil }
|
||||
|
||||
// Get returns a private copy; change it freely.
|
||||
// Get returns the settings with the environment applied.
|
||||
func (h *Holder) Get() Config {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return clone(h.cfg)
|
||||
}
|
||||
|
||||
func clone(c Config) Config {
|
||||
b, _ := json.Marshal(c)
|
||||
var out Config
|
||||
json.Unmarshal(b, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Update applies fn to a copy, validates it, saves it and only then makes it current.
|
||||
func (h *Holder) Update(fn func(*Config) error) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
c := clone(h.cfg)
|
||||
if err := fn(&c); err != nil {
|
||||
return err
|
||||
c := h.file
|
||||
c.Allowed = append([]string{}, c.Allowed...)
|
||||
h.mu.RUnlock()
|
||||
env := func(name string, to *string) {
|
||||
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||
*to = v
|
||||
}
|
||||
}
|
||||
env("BASKING_LISTEN", &c.Listen)
|
||||
env("BASKING_DATA", &c.DataDir)
|
||||
env("BASKING_PUBLIC_URL", &c.PublicURL)
|
||||
env("BASKING_OIDC_ISSUER", &c.OIDC.Issuer)
|
||||
env("BASKING_OIDC_CLIENT_ID", &c.OIDC.ClientID)
|
||||
env("BASKING_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
|
||||
if v := os.Getenv("BASKING_ALLOWED"); strings.TrimSpace(v) != "" {
|
||||
c.Allowed = nil
|
||||
for _, a := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) {
|
||||
if a = strings.TrimSpace(a); a != "" {
|
||||
c.Allowed = append(c.Allowed, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Defaults()
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := save(h.path, c); err != nil {
|
||||
return err
|
||||
}
|
||||
h.cfg = c
|
||||
return nil
|
||||
return c
|
||||
}
|
||||
|
||||
func save(path string, c Config) error {
|
||||
b, err := json.MarshalIndent(c, "", " ")
|
||||
// Save writes the file as it is now, which is what makes the generated secret stick.
|
||||
func (h *Holder) Save() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
b, err := json.MarshalIndent(h.file, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "." {
|
||||
if dir := filepath.Dir(h.path); dir != "." {
|
||||
os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
tmp := h.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// ---- passwords and tokens ----
|
||||
|
||||
const pbkdf2Rounds = 600_000
|
||||
|
||||
// HashPassword returns "pbkdf2$rounds$salt$key".
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Rounds, 32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("pbkdf2$%d$%s$%s", pbkdf2Rounds, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
// CheckPassword compares in constant time. An unparseable hash never matches.
|
||||
func CheckPassword(hash, password string) bool {
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2" {
|
||||
return false
|
||||
}
|
||||
rounds, err := strconv.Atoi(parts[1])
|
||||
if err != nil || rounds < 1 || rounds > 10_000_000 {
|
||||
return false
|
||||
}
|
||||
salt, err1 := base64.RawStdEncoding.DecodeString(parts[2])
|
||||
want, err2 := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
got, err := pbkdf2.Key(sha256.New, password, salt, rounds, len(want))
|
||||
return err == nil && subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
// NewToken makes an API token and the hash to store for it. The token itself is shown once.
|
||||
func NewToken() (token, hash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err = rand.Read(b); err != nil {
|
||||
return
|
||||
}
|
||||
token = "bsk_" + base64.RawURLEncoding.EncodeToString(b)
|
||||
return token, HashToken(token), nil
|
||||
}
|
||||
|
||||
func HashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// RandomPassword makes a password for a first run.
|
||||
func RandomPassword() string {
|
||||
b := make([]byte, 12)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
return os.Rename(tmp, h.path)
|
||||
}
|
||||
Reference in new issue
Block a user