164 lines
4.9 KiB
Go
164 lines
4.9 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{
|
|
"": "",
|
|
"files": "files",
|
|
}
|
|
|
|
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, s.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
|
|
}
|
|
|
|
if err := s.logIn(w, r, token, r.PostFormValue("persist") != ""); err != nil {
|
|
// Sealing needs nothing but randomness, so this is the machine failing
|
|
// rather than the caller: say so instead of leaving them logged out
|
|
// with no explanation.
|
|
s.log.Error("sealing the session", "err", err)
|
|
s.fail(w, r, http.StatusInternalServerError, "Could not start a session.")
|
|
return
|
|
}
|
|
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)
|
|
})
|
|
}
|