168 lines
4.5 KiB
Go
168 lines
4.5 KiB
Go
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 != ""})
|
|
}
|