Files
uncensored-send/internal/server/login.go
T

157 lines
4.5 KiB
Go

package server
import (
"net/http"
"net/url"
"strings"
"uncensored-send/internal/config"
)
// loginPage backs both the form and its error redisplay.
type loginPage struct {
page
Error string
Next string
// Limits describe what the presented credential would be allowed to do,
// shown once logged in so the upload page does not have to guess.
MaxSize string
MaxExpiry string
Vanity bool
}
// loginDestinations is the allowlist for the post-login redirect. Restricting
// it to known page names means the parameter can never name somewhere else.
var loginDestinations = map[string]string{
"": "",
"admin": "admin",
}
func destination(next string) string {
page, ok := loginDestinations[next]
if !ok {
return ""
}
return page
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
next := destination(r.URL.Query().Get("next"))
// Already logged in: say so rather than showing an empty form.
if lim, err := s.limitsFor(r, cookieCredential(r)); err == nil && !lim.Anonymous() {
s.render(w, http.StatusOK, "login.html", loginPage{
page: s.page(r, "Log in", false),
Next: next,
MaxSize: config.FormatSize(lim.MaxSize),
MaxExpiry: config.FormatLifetime(lim.MaxExpiry),
Vanity: lim.AllowVanity,
})
return
}
s.render(w, http.StatusOK, "login.html", loginPage{
page: s.page(r, "Log in", false),
Next: next,
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFieldBytes)
if err := r.ParseForm(); err != nil {
s.fail(w, r, http.StatusBadRequest, "Malformed form submission.")
return
}
token := strings.TrimSpace(r.PostFormValue("token"))
next := destination(r.PostFormValue("next"))
if token == "" {
s.loginFailed(w, r, next, http.StatusBadRequest, "Enter a token.")
return
}
// Only failures are throttled, so logging in normally is never delayed.
lim, err := s.limitsFor(r, token)
if err != nil {
if !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) {
s.loginFailed(w, r, next, http.StatusTooManyRequests,
"Too many failed attempts; try again shortly.")
return
}
s.log.Info("failed login", "ip", clientIP(r, s.cfg))
s.loginFailed(w, r, next, http.StatusUnauthorized, "That token is not recognised.")
return
}
s.logIn(w, r, token, r.PostFormValue("persist") != "")
s.log.Info("logged in", "name", lim.Name, "ip", clientIP(r, s.cfg))
if wantsJSON(r) {
writeJSON(w, http.StatusOK, map[string]string{"status": "logged in", "name": lim.Name})
return
}
http.Redirect(w, r, s.cfg.BasePath+next, http.StatusSeeOther)
}
func (s *Server) loginFailed(w http.ResponseWriter, r *http.Request, next string, status int, msg string) {
if wantsJSON(r) {
s.fail(w, r, status, msg)
return
}
s.render(w, status, "login.html", loginPage{
page: s.page(r, "Log in", false),
Error: msg,
Next: next,
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.forget(w, r)
if wantsJSON(r) {
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
return
}
http.Redirect(w, r, s.cfg.BasePath, http.StatusSeeOther)
}
// sameOrigin guards the state-changing routes against cross-site form posts.
//
// The cookie is SameSite=Strict, which already stops another site from acting
// as a logged-in user. This covers the case that does not need a cookie at all:
// a hostile page posting to /login to sign a visitor into an account the
// attacker controls, so that the visitor's uploads land under it.
//
// Browsers label their own requests; API clients send neither header, and their
// bearer tokens are not attachable by a third party anyway. So an absent label
// is allowed and a present one must say same-origin.
func sameOrigin(r *http.Request) bool {
switch r.Header.Get("Sec-Fetch-Site") {
case "same-origin", "none":
return true
case "": // older browser, or not a browser at all; fall through to Origin
default:
return false
}
origin := r.Header.Get("Origin")
if origin == "" || origin == "null" {
return origin == ""
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return u.Host == r.Host
}
func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && !sameOrigin(r) {
s.log.Info("rejected cross-origin post", "path", r.URL.Path,
"origin", r.Header.Get("Origin"), "ip", clientIP(r, s.cfg))
s.fail(w, r, http.StatusForbidden, "Cross-site form submissions are not accepted.")
return
}
next.ServeHTTP(w, r)
})
}