Add an optional site password for visitors
Settings → Privacy can set a password for the whole public site. Until a visitor enters it, every public page shows a prompt over a blurred made-up page, and images, preview clips and exports return 401; nothing real is sent. Unlocking sets a 90-day cookie derived from the password hash, so changing the password asks everyone again. The owner is never asked while signed in. Wrong guesses are rate limited, and a locked site sends noindex. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
568162202b
commit
06097d3bca
11 files changed
+302
-9
No files matched your search
@@ -499,6 +499,7 @@ func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"secrets": secrets,
|
||||
"themes": Themes,
|
||||
"username": u.Username,
|
||||
"sitePassword": set[store.SettingSitePassword] != "",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/canon/internal/auth"
|
||||
"github.com/cdnutter/canon/internal/store"
|
||||
)
|
||||
|
||||
// The site password: when the owner sets one, visitors see a password
|
||||
// prompt over a blurred placeholder instead of the site. Nothing from the
|
||||
// real page is sent until they unlock it.
|
||||
|
||||
const (
|
||||
accessCookie = "canon_access"
|
||||
accessTTL = 90 * 24 * time.Hour
|
||||
MinSitePasswordLen = 4
|
||||
)
|
||||
|
||||
type gatePage struct {
|
||||
Error string
|
||||
Next string
|
||||
}
|
||||
|
||||
func (s *Server) sitePasswordHash(ctx context.Context) string {
|
||||
set, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("loading settings", "err", err)
|
||||
return ""
|
||||
}
|
||||
return set[store.SettingSitePassword]
|
||||
}
|
||||
|
||||
// accessToken is what the access cookie holds. It's derived from the bcrypt
|
||||
// hash, which never leaves the server, so it can't be forged, and changing
|
||||
// the password signs every visitor out.
|
||||
func accessToken(hash string) string {
|
||||
sum := sha256.Sum256([]byte("canon-access\x00" + hash))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// hasAccess reports whether r may see the public site, and whether the site
|
||||
// is locked at all.
|
||||
func (s *Server) hasAccess(r *http.Request) (ok, locked bool) {
|
||||
hash := s.sitePasswordHash(r.Context())
|
||||
if hash == "" {
|
||||
return true, false
|
||||
}
|
||||
if _, ok := s.currentUser(r); ok {
|
||||
return true, true
|
||||
}
|
||||
c, err := r.Cookie(accessCookie)
|
||||
if err != nil {
|
||||
return false, true
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(accessToken(hash))) == 1, true
|
||||
}
|
||||
|
||||
// gated guards a public page, showing the password prompt instead.
|
||||
func (s *Server) gated(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ok, locked := s.hasAccess(r)
|
||||
if locked {
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
}
|
||||
if !ok {
|
||||
s.render(w, r, http.StatusUnauthorized, "gate", "", gatePage{Next: r.URL.RequestURI()})
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// gatedAsset guards images, clips and downloads.
|
||||
func (s *Server) gatedAsset(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if ok, _ := s.hasAccess(r); !ok {
|
||||
http.Error(w, "This site is password protected", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// safePublicNext is safeNext for visitors: it falls back to the home page.
|
||||
func safePublicNext(next string) string {
|
||||
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
|
||||
return "/"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *Server) handleUnlock(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
http.Error(w, "Cross-site request blocked", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
password := r.PostFormValue("password")
|
||||
next := safePublicNext(r.PostFormValue("next"))
|
||||
hash := s.sitePasswordHash(r.Context())
|
||||
if hash == "" {
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
page := gatePage{Next: next}
|
||||
ip := s.clientIP(r)
|
||||
if s.loginLimit.Blocked("unlock:" + ip) {
|
||||
page.Error = "Too many wrong tries. Wait a few minutes and try again."
|
||||
s.render(w, r, http.StatusTooManyRequests, "gate", "", page)
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(hash, password) {
|
||||
s.loginLimit.Fail("unlock:" + ip)
|
||||
page.Error = "That's not the password."
|
||||
s.render(w, r, http.StatusUnauthorized, "gate", "", page)
|
||||
return
|
||||
}
|
||||
s.loginLimit.Reset("unlock:" + ip)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: accessCookie,
|
||||
Value: accessToken(hash),
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(accessTTL),
|
||||
HttpOnly: true,
|
||||
Secure: s.isHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) apiSitePassword(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
hash := ""
|
||||
if in.Password != "" {
|
||||
if len([]rune(in.Password)) < MinSitePasswordLen {
|
||||
s.writeErr(w, store.ValidationError{Msg: "Use at least 4 characters"})
|
||||
return
|
||||
}
|
||||
if len(in.Password) > 72 {
|
||||
s.writeErr(w, store.ValidationError{Msg: "Use at most 72 characters"})
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if hash, err = auth.HashPassword(in.Password); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.SetSettings(r.Context(), map[string]string{store.SettingSitePassword: hash}); err != nil {
|
||||
s.writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"sitePassword": hash != ""})
|
||||
}
|
||||
@@ -32,6 +32,7 @@ type Site struct {
|
||||
type pageData struct {
|
||||
Site Site
|
||||
PageTitle string
|
||||
Page string // template name
|
||||
LoggedIn bool
|
||||
Username string
|
||||
Path string
|
||||
@@ -195,6 +196,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, status int, page
|
||||
pd := pageData{
|
||||
Site: s.site(r.Context()),
|
||||
PageTitle: title,
|
||||
Page: page,
|
||||
Path: r.URL.Path,
|
||||
AssetVer: s.assetVer,
|
||||
Data: data,
|
||||
|
||||
@@ -104,11 +104,12 @@ func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Public
|
||||
mux.HandleFunc("GET /{$}", s.handleHome)
|
||||
mux.HandleFunc("GET /l/{slug}", s.handleList)
|
||||
mux.HandleFunc("GET /l/{slug}/export", s.handleExport)
|
||||
mux.HandleFunc("GET /preview/{id}", s.handleItemPreview)
|
||||
mux.HandleFunc("GET /images/{name}", s.handleImage)
|
||||
mux.HandleFunc("GET /{$}", s.gated(s.handleHome))
|
||||
mux.HandleFunc("GET /l/{slug}", s.gated(s.handleList))
|
||||
mux.HandleFunc("GET /l/{slug}/export", s.gatedAsset(s.handleExport))
|
||||
mux.HandleFunc("GET /preview/{id}", s.gatedAsset(s.handleItemPreview))
|
||||
mux.HandleFunc("GET /images/{name}", s.gatedAsset(s.handleImage))
|
||||
mux.HandleFunc("POST /unlock", s.handleUnlock)
|
||||
mux.HandleFunc("GET /custom.css", s.handleCustomCSS)
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
|
||||
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -151,6 +152,7 @@ func (s *Server) Handler() http.Handler {
|
||||
api("DELETE /api/settings/avatar", s.apiDeleteAvatar)
|
||||
api("POST /api/integrations/test", s.apiTestIntegration)
|
||||
api("PUT /api/account", s.apiUpdateAccount)
|
||||
api("PUT /api/site-password", s.apiSitePassword)
|
||||
api("GET /api/backups", s.apiBackups)
|
||||
api("POST /api/backups", s.apiCreateBackup)
|
||||
api("PUT /api/backups/schedule", s.apiBackupSchedule)
|
||||
|
||||
Reference in new issue
Block a user