Add HTML login form (fixes #4137) (#8757)

This commit is contained in:
Emil Lundberg
2023-10-06 13:00:58 +02:00
committed by GitHub
parent ac2e444a97
commit 8294870ffc
12 changed files with 683 additions and 244 deletions
+144 -45
View File
@@ -19,6 +19,7 @@ import (
"github.com/syncthing/syncthing/lib/events"
"github.com/syncthing/syncthing/lib/rand"
"github.com/syncthing/syncthing/lib/sync"
"golang.org/x/exp/slices"
)
var (
@@ -37,6 +38,48 @@ func emitLoginAttempt(success bool, username, address string, evLogger events.Lo
}
}
func antiBruteForceSleep() {
time.Sleep(time.Duration(rand.Intn(100)+100) * time.Millisecond)
}
func unauthorized(w http.ResponseWriter) {
antiBruteForceSleep()
w.Header().Set("WWW-Authenticate", "Basic realm=\"Authorization Required\"")
http.Error(w, "Not Authorized", http.StatusUnauthorized)
}
func forbidden(w http.ResponseWriter) {
antiBruteForceSleep()
http.Error(w, "Forbidden", http.StatusForbidden)
}
func isNoAuthPath(path string) bool {
// Local variable instead of module var to prevent accidental mutation
noAuthPaths := []string{
"/",
"/index.html",
"/modal.html",
"/rest/svc/lang", // Required to load language settings on login page
}
// Local variable instead of module var to prevent accidental mutation
noAuthPrefixes := []string{
// Static assets
"/assets/",
"/syncthing/",
"/vendor/",
"/theme-assets/", // This leaks information from config, but probably not sensitive
// No-auth API endpoints
"/rest/noauth",
}
return slices.Contains(noAuthPaths, path) ||
slices.ContainsFunc(noAuthPrefixes, func(prefix string) bool {
return strings.HasPrefix(path, prefix)
})
}
func basicAuthAndSessionMiddleware(cookieName string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, next http.Handler, evLogger events.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hasValidAPIKeyHeader(r, guiCfg) {
@@ -44,8 +87,8 @@ func basicAuthAndSessionMiddleware(cookieName string, guiCfg config.GUIConfigura
return
}
// Exception for REST calls that don't require authentication.
if strings.HasPrefix(r.URL.Path, "/rest/noauth") {
// Exception for static assets and REST calls that don't require authentication.
if isNoAuthPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
@@ -61,60 +104,116 @@ func basicAuthAndSessionMiddleware(cookieName string, guiCfg config.GUIConfigura
}
}
l.Debugln("Sessionless HTTP request with authentication; this is expensive.")
error := func() {
time.Sleep(time.Duration(rand.Intn(100)+100) * time.Millisecond)
w.Header().Set("WWW-Authenticate", "Basic realm=\"Authorization Required\"")
http.Error(w, "Not Authorized", http.StatusUnauthorized)
}
username, password, ok := r.BasicAuth()
if !ok {
error()
// Fall back to Basic auth if provided
if username, ok := attemptBasicAuth(r, guiCfg, ldapCfg, evLogger); ok {
createSession(cookieName, username, guiCfg, evLogger, w, r)
next.ServeHTTP(w, r)
return
}
authOk := auth(username, password, guiCfg, ldapCfg)
if !authOk {
usernameIso := string(iso88591ToUTF8([]byte(username)))
passwordIso := string(iso88591ToUTF8([]byte(password)))
authOk = auth(usernameIso, passwordIso, guiCfg, ldapCfg)
if authOk {
username = usernameIso
}
}
if !authOk {
emitLoginAttempt(false, username, r.RemoteAddr, evLogger)
error()
// Some browsers don't send the Authorization request header unless prompted by a 401 response.
// This enables https://user:pass@localhost style URLs to keep working.
if guiCfg.SendBasicAuthPrompt {
unauthorized(w)
return
}
sessionid := rand.String(32)
sessionsMut.Lock()
sessions[sessionid] = true
sessionsMut.Unlock()
forbidden(w)
})
}
// Best effort detection of whether the connection is HTTPS --
// either directly to us, or as used by the client towards a reverse
// proxy who sends us headers.
connectionIsHTTPS := r.TLS != nil ||
strings.ToLower(r.Header.Get("x-forwarded-proto")) == "https" ||
strings.Contains(strings.ToLower(r.Header.Get("forwarded")), "proto=https")
// If the connection is HTTPS, or *should* be HTTPS, set the Secure
// bit in cookies.
useSecureCookie := connectionIsHTTPS || guiCfg.UseTLS()
func passwordAuthHandler(cookieName string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, evLogger events.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string
Password string
}
if err := unmarshalTo(r.Body, &req); err != nil {
l.Debugln("Failed to parse username and password:", err)
http.Error(w, "Failed to parse username and password.", http.StatusBadRequest)
return
}
if auth(req.Username, req.Password, guiCfg, ldapCfg) {
createSession(cookieName, req.Username, guiCfg, evLogger, w, r)
w.WriteHeader(http.StatusNoContent)
return
}
emitLoginAttempt(false, req.Username, r.RemoteAddr, evLogger)
forbidden(w)
})
}
func attemptBasicAuth(r *http.Request, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, evLogger events.Logger) (string, bool) {
username, password, ok := r.BasicAuth()
if !ok {
return "", false
}
l.Debugln("Sessionless HTTP request with authentication; this is expensive.")
if auth(username, password, guiCfg, ldapCfg) {
return username, true
}
usernameFromIso := string(iso88591ToUTF8([]byte(username)))
passwordFromIso := string(iso88591ToUTF8([]byte(password)))
if auth(usernameFromIso, passwordFromIso, guiCfg, ldapCfg) {
return usernameFromIso, true
}
emitLoginAttempt(false, username, r.RemoteAddr, evLogger)
return "", false
}
func createSession(cookieName string, username string, guiCfg config.GUIConfiguration, evLogger events.Logger, w http.ResponseWriter, r *http.Request) {
sessionid := rand.String(32)
sessionsMut.Lock()
sessions[sessionid] = true
sessionsMut.Unlock()
// Best effort detection of whether the connection is HTTPS --
// either directly to us, or as used by the client towards a reverse
// proxy who sends us headers.
connectionIsHTTPS := r.TLS != nil ||
strings.ToLower(r.Header.Get("x-forwarded-proto")) == "https" ||
strings.Contains(strings.ToLower(r.Header.Get("forwarded")), "proto=https")
// If the connection is HTTPS, or *should* be HTTPS, set the Secure
// bit in cookies.
useSecureCookie := connectionIsHTTPS || guiCfg.UseTLS()
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: sessionid,
// In HTTP spec Max-Age <= 0 means delete immediately,
// but in http.Cookie MaxAge = 0 means unspecified (session) and MaxAge < 0 means delete immediately
MaxAge: 0,
Secure: useSecureCookie,
Path: "/",
})
emitLoginAttempt(true, username, r.RemoteAddr, evLogger)
}
func handleLogout(cookieName string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(cookieName)
if err == nil && cookie != nil {
sessionsMut.Lock()
delete(sessions, cookie.Value)
sessionsMut.Unlock()
}
// else: If there is no session cookie, that's also a successful logout in terms of user experience.
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: sessionid,
MaxAge: 0,
Secure: useSecureCookie,
Value: "",
MaxAge: -1,
Secure: true,
Path: "/",
})
emitLoginAttempt(true, username, r.RemoteAddr, evLogger)
next.ServeHTTP(w, r)
w.WriteHeader(http.StatusNoContent)
})
}