config.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. // Package ldap provide functions & structure to query a LDAP ldap directory.
  2. // For now, it's mainly tested again an MS Active Directory service, see README.md for more information.
  3. package ldap
  4. import (
  5. "crypto/tls"
  6. "fmt"
  7. "strings"
  8. "github.com/go-ldap/ldap/v3"
  9. log "unknwon.dev/clog/v2"
  10. )
  11. // SecurityProtocol is the security protocol when the authenticate provider talks to LDAP directory.
  12. type SecurityProtocol int
  13. // ⚠️ WARNING: new type must be added at the end of list to maintain compatibility.
  14. const (
  15. SecurityProtocolUnencrypted SecurityProtocol = iota
  16. SecurityProtocolLDAPS
  17. SecurityProtocolStartTLS
  18. )
  19. // SecurityProtocolName returns the human-readable name for given security protocol.
  20. func SecurityProtocolName(protocol SecurityProtocol) string {
  21. return map[SecurityProtocol]string{
  22. SecurityProtocolUnencrypted: "Unencrypted",
  23. SecurityProtocolLDAPS: "LDAPS",
  24. SecurityProtocolStartTLS: "StartTLS",
  25. }[protocol]
  26. }
  27. // Config contains configuration for LDAP authentication.
  28. //
  29. // ⚠️ WARNING: Change to the field name must preserve the INI key name for backward compatibility.
  30. type Config struct {
  31. Host string // LDAP host
  32. Port int // Port number
  33. SecurityProtocol SecurityProtocol
  34. SkipVerify bool
  35. BindDN string `ini:"bind_dn,omitempty"` // DN to bind with
  36. BindPassword string `ini:",omitempty"` // Bind DN password
  37. UserBase string `ini:",omitempty"` // Base search path for users
  38. UserDN string `ini:"user_dn,omitempty"` // Template for the DN of the user for simple auth
  39. AttributeUsername string // Username attribute
  40. AttributeName string // First name attribute
  41. AttributeSurname string // Surname attribute
  42. AttributeMail string // Email attribute
  43. AttributesInBind bool // Fetch attributes in bind context (not user)
  44. Filter string // Query filter to validate entry
  45. AdminFilter string // Query filter to check if user is admin
  46. GroupEnabled bool // Whether the group checking is enabled
  47. GroupDN string `ini:"group_dn"` // Group search base
  48. GroupFilter string // Group name filter
  49. GroupMemberUID string `ini:"group_member_uid"` // Group Attribute containing array of UserUID
  50. UserUID string `ini:"user_uid"` // User Attribute listed in group
  51. }
  52. func (c *Config) SecurityProtocolName() string {
  53. return SecurityProtocolName(c.SecurityProtocol)
  54. }
  55. func (c *Config) sanitizedUserQuery(username string) (string, bool) {
  56. // See http://tools.ietf.org/search/rfc4515
  57. badCharacters := "\x00()*\\"
  58. if strings.ContainsAny(username, badCharacters) {
  59. log.Trace("LDAP: Username contains invalid query characters: %s", username)
  60. return "", false
  61. }
  62. return strings.ReplaceAll(c.Filter, "%s", username), true
  63. }
  64. func (c *Config) sanitizedUserDN(username string) (string, bool) {
  65. // See http://tools.ietf.org/search/rfc4514: "special characters"
  66. badCharacters := "\x00()*\\,='\"#+;<>"
  67. if strings.ContainsAny(username, badCharacters) || strings.HasPrefix(username, " ") || strings.HasSuffix(username, " ") {
  68. log.Trace("LDAP: Username contains invalid query characters: %s", username)
  69. return "", false
  70. }
  71. return strings.ReplaceAll(c.UserDN, "%s", username), true
  72. }
  73. func (*Config) sanitizedGroupFilter(group string) (string, bool) {
  74. // See http://tools.ietf.org/search/rfc4515
  75. badCharacters := "\x00*\\"
  76. if strings.ContainsAny(group, badCharacters) {
  77. log.Trace("LDAP: Group filter invalid query characters: %s", group)
  78. return "", false
  79. }
  80. return group, true
  81. }
  82. func (*Config) sanitizedGroupDN(groupDn string) (string, bool) {
  83. // See http://tools.ietf.org/search/rfc4514: "special characters"
  84. badCharacters := "\x00()*\\'\"#+;<>"
  85. if strings.ContainsAny(groupDn, badCharacters) || strings.HasPrefix(groupDn, " ") || strings.HasSuffix(groupDn, " ") {
  86. log.Trace("LDAP: Group DN contains invalid query characters: %s", groupDn)
  87. return "", false
  88. }
  89. return groupDn, true
  90. }
  91. func (c *Config) findUserDN(l *ldap.Conn, name string) (string, bool) {
  92. log.Trace("Search for LDAP user: %s", name)
  93. if len(c.BindDN) > 0 && len(c.BindPassword) > 0 {
  94. // Replace placeholders with username
  95. bindDN := strings.ReplaceAll(c.BindDN, "%s", name)
  96. err := l.Bind(bindDN, c.BindPassword)
  97. if err != nil {
  98. log.Trace("LDAP: Failed to bind as BindDN '%s': %v", bindDN, err)
  99. return "", false
  100. }
  101. log.Trace("LDAP: Bound as BindDN: %s", bindDN)
  102. } else {
  103. log.Trace("LDAP: Proceeding with anonymous LDAP search")
  104. }
  105. // A search for the user.
  106. userFilter, ok := c.sanitizedUserQuery(name)
  107. if !ok {
  108. return "", false
  109. }
  110. log.Trace("LDAP: Searching for DN using filter %q and base %q", userFilter, c.UserBase)
  111. search := ldap.NewSearchRequest(
  112. c.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0,
  113. false, userFilter, []string{}, nil)
  114. // Ensure we found a user
  115. sr, err := l.Search(search)
  116. if err != nil || len(sr.Entries) < 1 {
  117. log.Trace("LDAP: Failed to search using filter %q: %v", userFilter, err)
  118. return "", false
  119. } else if len(sr.Entries) > 1 {
  120. log.Trace("LDAP: Filter %q returned more than one user", userFilter)
  121. return "", false
  122. }
  123. userDN := sr.Entries[0].DN
  124. if userDN == "" {
  125. log.Error("LDAP: Search was successful, but found no DN!")
  126. return "", false
  127. }
  128. return userDN, true
  129. }
  130. func dial(ls *Config) (*ldap.Conn, error) {
  131. log.Trace("LDAP: Dialing with security protocol '%v' without verifying: %v", ls.SecurityProtocol, ls.SkipVerify)
  132. tlsCfg := &tls.Config{
  133. ServerName: ls.Host,
  134. InsecureSkipVerify: ls.SkipVerify,
  135. }
  136. if ls.SecurityProtocol == SecurityProtocolLDAPS {
  137. return ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", ls.Host, ls.Port), tlsCfg)
  138. }
  139. conn, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", ls.Host, ls.Port))
  140. if err != nil {
  141. return nil, fmt.Errorf("dial: %v", err)
  142. }
  143. if ls.SecurityProtocol == SecurityProtocolStartTLS {
  144. if err = conn.StartTLS(tlsCfg); err != nil {
  145. conn.Close()
  146. return nil, fmt.Errorf("StartTLS: %v", err)
  147. }
  148. }
  149. return conn, nil
  150. }
  151. func bindUser(l *ldap.Conn, userDN, passwd string) error {
  152. log.Trace("Binding with userDN: %s", userDN)
  153. err := l.Bind(userDN, passwd)
  154. if err != nil {
  155. log.Trace("LDAP authentication failed for '%s': %v", userDN, err)
  156. return err
  157. }
  158. log.Trace("Bound successfully with userDN: %s", userDN)
  159. return err
  160. }
  161. // searchEntry searches an LDAP source if an entry (name, passwd) is valid and in the specific filter.
  162. func (c *Config) searchEntry(name, passwd string, directBind bool) (string, string, string, string, bool, bool) {
  163. // See https://tools.ietf.org/search/rfc4513#section-5.1.2
  164. if passwd == "" {
  165. log.Trace("authentication failed for '%s' with empty password", name)
  166. return "", "", "", "", false, false
  167. }
  168. l, err := dial(c)
  169. if err != nil {
  170. log.Error("LDAP connect failed for '%s': %v", c.Host, err)
  171. return "", "", "", "", false, false
  172. }
  173. defer l.Close()
  174. var userDN string
  175. if directBind {
  176. log.Trace("LDAP will bind directly via UserDN template: %s", c.UserDN)
  177. var ok bool
  178. userDN, ok = c.sanitizedUserDN(name)
  179. if !ok {
  180. return "", "", "", "", false, false
  181. }
  182. } else {
  183. log.Trace("LDAP will use BindDN")
  184. var found bool
  185. userDN, found = c.findUserDN(l, name)
  186. if !found {
  187. return "", "", "", "", false, false
  188. }
  189. }
  190. if directBind || !c.AttributesInBind {
  191. // binds user (checking password) before looking-up attributes in user context
  192. err = bindUser(l, userDN, passwd)
  193. if err != nil {
  194. return "", "", "", "", false, false
  195. }
  196. }
  197. userFilter, ok := c.sanitizedUserQuery(name)
  198. if !ok {
  199. return "", "", "", "", false, false
  200. }
  201. log.Trace("Fetching attributes %q, %q, %q, %q, %q with user filter %q and user DN %q",
  202. c.AttributeUsername, c.AttributeName, c.AttributeSurname, c.AttributeMail, c.UserUID, userFilter, userDN)
  203. search := ldap.NewSearchRequest(
  204. userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter,
  205. []string{c.AttributeUsername, c.AttributeName, c.AttributeSurname, c.AttributeMail, c.UserUID},
  206. nil)
  207. sr, err := l.Search(search)
  208. if err != nil {
  209. log.Error("LDAP: User search failed: %v", err)
  210. return "", "", "", "", false, false
  211. } else if len(sr.Entries) < 1 {
  212. if directBind {
  213. log.Trace("LDAP: User filter inhibited user login")
  214. } else {
  215. log.Trace("LDAP: User search failed: 0 entries")
  216. }
  217. return "", "", "", "", false, false
  218. }
  219. username := sr.Entries[0].GetAttributeValue(c.AttributeUsername)
  220. firstname := sr.Entries[0].GetAttributeValue(c.AttributeName)
  221. surname := sr.Entries[0].GetAttributeValue(c.AttributeSurname)
  222. mail := sr.Entries[0].GetAttributeValue(c.AttributeMail)
  223. uid := sr.Entries[0].GetAttributeValue(c.UserUID)
  224. // Check group membership
  225. if c.GroupEnabled {
  226. groupFilter, ok := c.sanitizedGroupFilter(c.GroupFilter)
  227. if !ok {
  228. return "", "", "", "", false, false
  229. }
  230. groupDN, ok := c.sanitizedGroupDN(c.GroupDN)
  231. if !ok {
  232. return "", "", "", "", false, false
  233. }
  234. log.Trace("LDAP: Fetching groups '%v' with filter '%s' and base '%s'", c.GroupMemberUID, groupFilter, groupDN)
  235. groupSearch := ldap.NewSearchRequest(
  236. groupDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, groupFilter,
  237. []string{c.GroupMemberUID},
  238. nil)
  239. srg, err := l.Search(groupSearch)
  240. if err != nil {
  241. log.Error("LDAP: Group search failed: %v", err)
  242. return "", "", "", "", false, false
  243. } else if len(srg.Entries) < 1 {
  244. log.Trace("LDAP: Group search returned no entries")
  245. return "", "", "", "", false, false
  246. }
  247. isMember := false
  248. if c.UserUID == "dn" {
  249. for _, group := range srg.Entries {
  250. for _, member := range group.GetAttributeValues(c.GroupMemberUID) {
  251. if member == sr.Entries[0].DN {
  252. isMember = true
  253. }
  254. }
  255. }
  256. } else {
  257. for _, group := range srg.Entries {
  258. for _, member := range group.GetAttributeValues(c.GroupMemberUID) {
  259. if member == uid {
  260. isMember = true
  261. }
  262. }
  263. }
  264. }
  265. if !isMember {
  266. log.Trace("LDAP: Group membership test failed [username: %s, group_member_uid: %s, user_uid: %s", username, c.GroupMemberUID, uid)
  267. return "", "", "", "", false, false
  268. }
  269. }
  270. isAdmin := false
  271. if len(c.AdminFilter) > 0 {
  272. log.Trace("Checking admin with filter '%s' and base '%s'", c.AdminFilter, userDN)
  273. search = ldap.NewSearchRequest(
  274. userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, c.AdminFilter,
  275. []string{c.AttributeName},
  276. nil)
  277. sr, err = l.Search(search)
  278. if err != nil {
  279. log.Error("LDAP: Admin search failed: %v", err)
  280. } else if len(sr.Entries) < 1 {
  281. log.Trace("LDAP: Admin search returned no entries")
  282. } else {
  283. isAdmin = true
  284. }
  285. }
  286. if !directBind && c.AttributesInBind {
  287. // binds user (checking password) after looking-up attributes in BindDN context
  288. err = bindUser(l, userDN, passwd)
  289. if err != nil {
  290. return "", "", "", "", false, false
  291. }
  292. }
  293. return username, firstname, surname, mail, isAdmin, true
  294. }