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 = "Password incorrect." 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 != ""}) }