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
@@ -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 != ""})
|
||||
}
|
||||
Reference in new issue
Block a user