api_auth.go 11 KB

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