api_auth.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. // Copyright (C) 2014 The Syncthing Authors.
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this file,
  5. // You can obtain one at https://mozilla.org/MPL/2.0/.
  6. package api
  7. import (
  8. "crypto/tls"
  9. "fmt"
  10. "log/slog"
  11. "net"
  12. "net/http"
  13. "slices"
  14. "strings"
  15. "time"
  16. ldap "github.com/go-ldap/ldap/v3"
  17. "github.com/syncthing/syncthing/internal/slogutil"
  18. "github.com/syncthing/syncthing/lib/config"
  19. "github.com/syncthing/syncthing/lib/events"
  20. "github.com/syncthing/syncthing/lib/osutil"
  21. "github.com/syncthing/syncthing/lib/rand"
  22. )
  23. const (
  24. maxSessionLifetime = 7 * 24 * time.Hour
  25. maxActiveSessions = 25
  26. randomTokenLength = 64
  27. maxLoginRequestSize = 1 << 10 // one kibibyte for username+password
  28. )
  29. func emitLoginAttempt(success bool, username string, r *http.Request, evLogger events.Logger) {
  30. remoteAddress, proxy := remoteAddress(r)
  31. evData := map[string]any{
  32. "success": success,
  33. "username": username,
  34. "remoteAddress": remoteAddress,
  35. }
  36. if proxy != "" {
  37. evData["proxy"] = proxy
  38. }
  39. evLogger.Log(events.LoginAttempt, evData)
  40. if success {
  41. return
  42. }
  43. l := slog.Default().With(slogutil.Address(remoteAddress), slog.String("username", username))
  44. if proxy != "" {
  45. l = l.With("proxy", proxy)
  46. }
  47. l.Warn("Bad credentials supplied during API authorization")
  48. }
  49. func remoteAddress(r *http.Request) (remoteAddr, proxy string) {
  50. remoteAddr = r.RemoteAddr
  51. remoteIP := osutil.IPFromString(r.RemoteAddr)
  52. // parse X-Forwarded-For only if the proxy connects via unix socket, localhost or a LAN IP
  53. var localProxy bool
  54. if remoteIP != nil {
  55. remoteAddr = remoteIP.String()
  56. localProxy = remoteIP.IsLoopback() || remoteIP.IsPrivate() || remoteIP.IsLinkLocalUnicast()
  57. } else if remoteAddr == "@" {
  58. localProxy = true
  59. }
  60. if !localProxy {
  61. return
  62. }
  63. forwardedAddr, _, _ := strings.Cut(r.Header.Get("X-Forwarded-For"), ",")
  64. forwardedAddr = strings.TrimSpace(forwardedAddr)
  65. forwardedIP := osutil.IPFromString(forwardedAddr)
  66. if forwardedIP != nil {
  67. proxy = remoteAddr
  68. remoteAddr = forwardedIP.String()
  69. }
  70. return
  71. }
  72. func antiBruteForceSleep() {
  73. time.Sleep(time.Duration(rand.Intn(100)+100) * time.Millisecond)
  74. }
  75. func unauthorized(w http.ResponseWriter, shortID string) {
  76. w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="Authorization Required (%s)"`, shortID))
  77. http.Error(w, "Not Authorized", http.StatusUnauthorized)
  78. }
  79. func forbidden(w http.ResponseWriter) {
  80. http.Error(w, "Forbidden", http.StatusForbidden)
  81. }
  82. func isNoAuthPath(path string, metricsWithoutAuth bool) bool {
  83. // Local variable instead of module var to prevent accidental mutation
  84. noAuthPaths := []string{
  85. "/",
  86. "/index.html",
  87. "/modal.html",
  88. "/rest/svc/lang", // Required to load language settings on login page
  89. }
  90. if metricsWithoutAuth {
  91. noAuthPaths = append(noAuthPaths, "/metrics")
  92. }
  93. // Local variable instead of module var to prevent accidental mutation
  94. noAuthPrefixes := []string{
  95. // Static assets
  96. "/assets/",
  97. "/syncthing/",
  98. "/vendor/",
  99. "/theme-assets/", // This leaks information from config, but probably not sensitive
  100. // No-auth API endpoints
  101. "/rest/noauth",
  102. }
  103. return slices.Contains(noAuthPaths, path) ||
  104. slices.ContainsFunc(noAuthPrefixes, func(prefix string) bool {
  105. return strings.HasPrefix(path, prefix)
  106. })
  107. }
  108. type basicAuthAndSessionMiddleware struct {
  109. tokenCookieManager *tokenCookieManager
  110. guiCfg config.GUIConfiguration
  111. ldapCfg config.LDAPConfiguration
  112. next http.Handler
  113. evLogger events.Logger
  114. }
  115. func newBasicAuthAndSessionMiddleware(tokenCookieManager *tokenCookieManager, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, next http.Handler, evLogger events.Logger) *basicAuthAndSessionMiddleware {
  116. return &basicAuthAndSessionMiddleware{
  117. tokenCookieManager: tokenCookieManager,
  118. guiCfg: guiCfg,
  119. ldapCfg: ldapCfg,
  120. next: next,
  121. evLogger: evLogger,
  122. }
  123. }
  124. func (m *basicAuthAndSessionMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  125. if hasValidAPIKeyHeader(r, m.guiCfg) {
  126. m.next.ServeHTTP(w, r)
  127. return
  128. }
  129. if m.tokenCookieManager.hasValidSession(r) {
  130. m.next.ServeHTTP(w, r)
  131. return
  132. }
  133. // Fall back to Basic auth if provided
  134. if username, ok := attemptBasicAuth(r, m.guiCfg, m.ldapCfg, m.evLogger); ok {
  135. m.tokenCookieManager.createSession(username, false, w, r)
  136. m.next.ServeHTTP(w, r)
  137. return
  138. }
  139. // Exception for static assets and REST calls that don't require authentication.
  140. if isNoAuthPath(r.URL.Path, m.guiCfg.MetricsWithoutAuth) {
  141. m.next.ServeHTTP(w, r)
  142. return
  143. }
  144. // Some browsers don't send the Authorization request header unless prompted by a 401 response.
  145. // This enables https://user:pass@localhost style URLs to keep working.
  146. if m.guiCfg.SendBasicAuthPrompt {
  147. unauthorized(w, m.tokenCookieManager.shortID)
  148. return
  149. }
  150. forbidden(w)
  151. }
  152. func (m *basicAuthAndSessionMiddleware) passwordAuthHandler(w http.ResponseWriter, r *http.Request) {
  153. var req struct {
  154. Username string
  155. Password string
  156. StayLoggedIn bool
  157. }
  158. if err := unmarshalTo(http.MaxBytesReader(w, r.Body, maxLoginRequestSize), &req); err != nil {
  159. l.Debugln("Failed to parse username and password:", err)
  160. http.Error(w, "Failed to parse username and password.", http.StatusBadRequest)
  161. return
  162. }
  163. if auth(req.Username, req.Password, m.guiCfg, m.ldapCfg) {
  164. m.tokenCookieManager.createSession(req.Username, req.StayLoggedIn, w, r)
  165. w.WriteHeader(http.StatusNoContent)
  166. return
  167. }
  168. emitLoginAttempt(false, req.Username, r, m.evLogger)
  169. antiBruteForceSleep()
  170. forbidden(w)
  171. }
  172. func attemptBasicAuth(r *http.Request, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration, evLogger events.Logger) (string, bool) {
  173. username, password, ok := r.BasicAuth()
  174. if !ok {
  175. return "", false
  176. }
  177. slog.Debug("Sessionless HTTP request with authentication; this is expensive.")
  178. if auth(username, password, guiCfg, ldapCfg) {
  179. return username, true
  180. }
  181. usernameFromIso := string(iso88591ToUTF8([]byte(username)))
  182. passwordFromIso := string(iso88591ToUTF8([]byte(password)))
  183. if auth(usernameFromIso, passwordFromIso, guiCfg, ldapCfg) {
  184. return usernameFromIso, true
  185. }
  186. emitLoginAttempt(false, username, r, evLogger)
  187. antiBruteForceSleep()
  188. return "", false
  189. }
  190. func (m *basicAuthAndSessionMiddleware) handleLogout(w http.ResponseWriter, r *http.Request) {
  191. m.tokenCookieManager.destroySession(w, r)
  192. w.WriteHeader(http.StatusNoContent)
  193. }
  194. func auth(username string, password string, guiCfg config.GUIConfiguration, ldapCfg config.LDAPConfiguration) bool {
  195. if guiCfg.AuthMode == config.AuthModeLDAP {
  196. return authLDAP(username, password, ldapCfg)
  197. } else {
  198. return authStatic(username, password, guiCfg)
  199. }
  200. }
  201. func authStatic(username string, password string, guiCfg config.GUIConfiguration) bool {
  202. return guiCfg.CompareHashedPassword(password) == nil && username == guiCfg.User
  203. }
  204. func authLDAP(username string, password string, cfg config.LDAPConfiguration) bool {
  205. address := cfg.Address
  206. hostname, _, err := net.SplitHostPort(address)
  207. if err != nil {
  208. hostname = address
  209. }
  210. var connection *ldap.Conn
  211. if cfg.Transport == config.LDAPTransportTLS {
  212. connection, err = ldap.DialTLS("tcp", address, &tls.Config{
  213. ServerName: hostname,
  214. InsecureSkipVerify: cfg.InsecureSkipVerify,
  215. })
  216. } else {
  217. connection, err = ldap.Dial("tcp", address)
  218. }
  219. if err != nil {
  220. slog.Error("Failed to dial LDAP server", slogutil.Error(err))
  221. return false
  222. }
  223. if cfg.Transport == config.LDAPTransportStartTLS {
  224. err = connection.StartTLS(&tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify})
  225. if err != nil {
  226. slog.Error("Failed to handshake start TLS With LDAP server", slogutil.Error(err))
  227. return false
  228. }
  229. }
  230. defer connection.Close()
  231. bindDN := formatOptionalPercentS(cfg.BindDN, escapeForLDAPDN(username))
  232. err = connection.Bind(bindDN, password)
  233. if err != nil {
  234. slog.Error("Failed to bind with LDAP server", slogutil.Error(err))
  235. return false
  236. }
  237. if cfg.SearchFilter == "" && cfg.SearchBaseDN == "" {
  238. // We're done here.
  239. return true
  240. }
  241. if cfg.SearchFilter == "" || cfg.SearchBaseDN == "" {
  242. slog.Error("Bad LDAP configuration: both searchFilter and searchBaseDN must be set, or neither")
  243. return false
  244. }
  245. // If a search filter and search base is set we do an LDAP search for
  246. // the user. If this matches precisely one user then we are good to go.
  247. // The search filter uses the same %s interpolation as the bind DN.
  248. searchString := formatOptionalPercentS(cfg.SearchFilter, escapeForLDAPFilter(username))
  249. const sizeLimit = 2 // we search for up to two users -- we only want to match one, so getting any number >1 is a failure.
  250. const timeLimit = 60 // Search for up to a minute...
  251. searchReq := ldap.NewSearchRequest(cfg.SearchBaseDN, ldap.ScopeWholeSubtree, ldap.DerefFindingBaseObj, sizeLimit, timeLimit, false, searchString, nil, nil)
  252. res, err := connection.Search(searchReq)
  253. if err != nil {
  254. slog.Warn("Failed LDAP search", slogutil.Error(err))
  255. return false
  256. }
  257. if len(res.Entries) != 1 {
  258. slog.Warn("Incorrect number of LDAP search results (expected one)", slog.Int("results", len(res.Entries)))
  259. return false
  260. }
  261. return true
  262. }
  263. // escapeForLDAPFilter escapes a value that will be used in a filter clause
  264. func escapeForLDAPFilter(value string) string {
  265. // https://social.technet.microsoft.com/wiki/contents/articles/5392.active-directory-ldap-syntax-filters.aspx#Special_Characters
  266. // Backslash must always be first in the list so we don't double escape them.
  267. return escapeRunes(value, []rune{'\\', '*', '(', ')', 0})
  268. }
  269. // escapeForLDAPDN escapes a value that will be used in a bind DN
  270. func escapeForLDAPDN(value string) string {
  271. // https://social.technet.microsoft.com/wiki/contents/articles/5312.active-directory-characters-to-escape.aspx
  272. // Backslash must always be first in the list so we don't double escape them.
  273. return escapeRunes(value, []rune{'\\', ',', '#', '+', '<', '>', ';', '"', '=', ' ', 0})
  274. }
  275. func escapeRunes(value string, runes []rune) string {
  276. for _, e := range runes {
  277. value = strings.ReplaceAll(value, string(e), fmt.Sprintf("\\%X", e))
  278. }
  279. return value
  280. }
  281. func formatOptionalPercentS(template string, username string) string {
  282. var replacements []any
  283. nReps := strings.Count(template, "%s") - strings.Count(template, "%%s")
  284. if nReps < 0 {
  285. nReps = 0
  286. }
  287. for i := 0; i < nReps; i++ {
  288. replacements = append(replacements, username)
  289. }
  290. return fmt.Sprintf(template, replacements...)
  291. }
  292. // Convert an ISO-8859-1 encoded byte string to UTF-8. Works by the
  293. // principle that ISO-8859-1 bytes are equivalent to unicode code points,
  294. // that a rune slice is a list of code points, and that stringifying a slice
  295. // of runes generates UTF-8 in Go.
  296. func iso88591ToUTF8(s []byte) []byte {
  297. runes := make([]rune, len(s))
  298. for i := range s {
  299. runes[i] = rune(s[i])
  300. }
  301. return []byte(string(runes))
  302. }