Add explicit login functionality
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"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(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.FormatDuration(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(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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user