identity.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. package identity
  2. /*
  3. ArozOS Cluster - identity service (AID)
  4. One member of the cluster can be elected the "identity origin" (SSO
  5. owner). Every other member then:
  6. 1. forwards login attempts to the origin (forward auth): the origin
  7. checks the SHA-512 password hash against its own account table and
  8. answers with the user's groups. The member mirrors the account
  9. locally (hash + groups that exist on the member) and opens a normal
  10. local session.
  11. 2. keeps a replicated copy of the origin's account directory so users
  12. can still log in when the origin is unreachable (fallback).
  13. 3. forwards password changes of replicated accounts back to the origin.
  14. Accounts that exist only on a member (never replicated) are left alone,
  15. so every node still works standalone. Group membership is mapped by group
  16. NAME: only groups that exist locally are applied, because groups carry
  17. node-specific storage and module settings.
  18. */
  19. import (
  20. "context"
  21. "errors"
  22. "net/http"
  23. "sort"
  24. "strings"
  25. "sync"
  26. "time"
  27. "imuslab.com/arozos/mod/auth"
  28. "imuslab.com/arozos/mod/cluster/acn"
  29. "imuslab.com/arozos/mod/cluster/membership"
  30. "imuslab.com/arozos/mod/database"
  31. "imuslab.com/arozos/mod/info/logger"
  32. )
  33. const (
  34. // DefaultSyncInterval is how often members pull the origin directory.
  35. DefaultSyncInterval = 5 * time.Minute
  36. forwardAuthTimeout = 12 * time.Second
  37. keyManagedPrefix = "managed/"
  38. )
  39. // AccountStore is what the identity service needs from the host's account
  40. // system. The core adapts the auth agent and permission handler to it.
  41. type AccountStore interface {
  42. UserExists(username string) bool
  43. PasswordHash(username string) (string, error)
  44. SetPasswordHash(username string, hash string) error
  45. Groups(username string) ([]string, error)
  46. SetGroups(username string, groups []string) error
  47. ListUsers() []string
  48. DeleteUser(username string) error
  49. GroupExists(group string) bool
  50. }
  51. // Account is one replicated user entry.
  52. type Account struct {
  53. Username string `json:"username"`
  54. PasswordHash string `json:"passwordHash"`
  55. Groups []string `json:"groups"`
  56. }
  57. // Directory is the origin's full account list.
  58. type Directory struct {
  59. Origin string `json:"origin"`
  60. Users []Account `json:"users"`
  61. Time int64 `json:"time"`
  62. }
  63. // Wire payloads
  64. type VerifyRequest struct {
  65. Username string `json:"username"`
  66. PasswordHash string `json:"passwordHash"`
  67. }
  68. type VerifyResponse struct {
  69. OK bool `json:"ok"`
  70. Reason string `json:"reason,omitempty"`
  71. Groups []string `json:"groups,omitempty"`
  72. }
  73. type SetPasswordRequest struct {
  74. Username string `json:"username"`
  75. PasswordHash string `json:"passwordHash"`
  76. }
  77. // Option configures the identity service.
  78. type Option struct {
  79. Membership *membership.Manager
  80. Accounts AccountStore
  81. SyncInterval time.Duration
  82. }
  83. // Manager is the identity service of this node.
  84. type Manager struct {
  85. m *membership.Manager
  86. acc AccountStore
  87. db *database.Database
  88. mu sync.Mutex
  89. managed map[string]bool //accounts on this node that mirror the origin
  90. lastSync int64
  91. lastSyncErr string
  92. lastForward int64
  93. lastForwardE string
  94. skipped []string //directory users without a matching local group
  95. conflicts []string //directory users shadowed by a local-only account
  96. interval time.Duration
  97. stop chan struct{}
  98. trigger chan struct{}
  99. once sync.Once
  100. }
  101. // New creates the identity service and registers its ACN endpoints.
  102. func New(opt Option) (*Manager, error) {
  103. if opt.Membership == nil || opt.Accounts == nil {
  104. return nil, errors.New("membership manager and account store are required")
  105. }
  106. if opt.SyncInterval <= 0 {
  107. opt.SyncInterval = DefaultSyncInterval
  108. }
  109. i := &Manager{
  110. m: opt.Membership,
  111. acc: opt.Accounts,
  112. db: opt.Membership.DB(),
  113. managed: map[string]bool{},
  114. interval: opt.SyncInterval,
  115. stop: make(chan struct{}),
  116. trigger: make(chan struct{}, 1),
  117. }
  118. i.db.NewTable(membership.TableIdentity)
  119. i.loadManaged()
  120. i.registerACNHandlers()
  121. prev := i.m.OnClusterChange
  122. i.m.OnClusterChange = func() {
  123. if prev != nil {
  124. prev()
  125. }
  126. i.TriggerSync()
  127. }
  128. go i.loop()
  129. return i, nil
  130. }
  131. // Close stops the background sync.
  132. func (i *Manager) Close() {
  133. i.once.Do(func() { close(i.stop) })
  134. }
  135. func (i *Manager) loadManaged() {
  136. entries, err := i.db.ListTable(membership.TableIdentity)
  137. if err != nil {
  138. return
  139. }
  140. i.mu.Lock()
  141. defer i.mu.Unlock()
  142. for _, kv := range entries {
  143. k := string(kv[0])
  144. if strings.HasPrefix(k, keyManagedPrefix) {
  145. i.managed[strings.TrimPrefix(k, keyManagedPrefix)] = true
  146. }
  147. }
  148. }
  149. func (i *Manager) markManaged(username string, origin string) {
  150. i.mu.Lock()
  151. i.managed[username] = true
  152. i.mu.Unlock()
  153. i.db.Write(membership.TableIdentity, keyManagedPrefix+username, origin)
  154. }
  155. func (i *Manager) unmarkManaged(username string) {
  156. i.mu.Lock()
  157. delete(i.managed, username)
  158. i.mu.Unlock()
  159. i.db.Delete(membership.TableIdentity, keyManagedPrefix+username)
  160. }
  161. // IsManaged reports whether an account on this node mirrors the origin.
  162. func (i *Manager) IsManaged(username string) bool {
  163. i.mu.Lock()
  164. defer i.mu.Unlock()
  165. return i.managed[username]
  166. }
  167. // Origin returns the identity origin node and whether it is this node.
  168. func (i *Manager) Origin() (nodeID string, isSelf bool) {
  169. origin := i.m.IdentityOrigin()
  170. return origin, origin != "" && origin == i.m.NodeID()
  171. }
  172. // forwardingActive is true when logins should be checked on another node.
  173. func (i *Manager) forwardingActive() (string, bool) {
  174. origin, self := i.Origin()
  175. if origin == "" || self || !i.m.InCluster() {
  176. return "", false
  177. }
  178. return origin, true
  179. }
  180. // localGroups keeps only the groups that exist on this node.
  181. func (i *Manager) localGroups(groups []string) []string {
  182. out := []string{}
  183. for _, g := range groups {
  184. if g != "" && i.acc.GroupExists(g) {
  185. out = append(out, g)
  186. }
  187. }
  188. return out
  189. }
  190. // mirrorAccount creates or updates the local copy of an origin account.
  191. func (i *Manager) mirrorAccount(origin string, username string, hash string, groups []string) error {
  192. if err := i.acc.SetPasswordHash(username, hash); err != nil {
  193. return err
  194. }
  195. if err := i.acc.SetGroups(username, groups); err != nil {
  196. return err
  197. }
  198. i.markManaged(username, origin)
  199. return nil
  200. }
  201. /*
  202. Forward authentication
  203. */
  204. // ForwardAuth is installed as the auth agent's ForwardAuth hook.
  205. func (i *Manager) ForwardAuth(username string, passwordHash string) auth.ForwardAuthResult {
  206. origin, active := i.forwardingActive()
  207. if !active {
  208. return auth.ForwardAuthResult{}
  209. }
  210. if !validUsername(username) {
  211. return auth.ForwardAuthResult{Decided: true, Accepted: false, Reason: "Invalid username or password"}
  212. }
  213. ctx, cancel := context.WithTimeout(context.Background(), forwardAuthTimeout)
  214. defer cancel()
  215. var resp VerifyResponse
  216. err := i.m.Transport().DoJSON(ctx, origin, http.MethodPost, acn.BasePath+"/auth/verify", VerifyRequest{Username: username, PasswordHash: passwordHash}, &resp)
  217. i.mu.Lock()
  218. i.lastForward = time.Now().Unix()
  219. if err != nil {
  220. i.lastForwardE = err.Error()
  221. } else {
  222. i.lastForwardE = ""
  223. }
  224. i.mu.Unlock()
  225. if err != nil {
  226. //Origin unreachable: let the local (replicated) table decide
  227. logger.PrintAndLog("Cluster", "Identity origin unreachable, using local accounts: "+err.Error(), nil)
  228. return auth.ForwardAuthResult{}
  229. }
  230. if !resp.OK {
  231. reason := resp.Reason
  232. if reason == "" {
  233. reason = "Invalid username or password"
  234. }
  235. return auth.ForwardAuthResult{Decided: true, Accepted: false, Reason: reason}
  236. }
  237. //Mirror the account locally so the session and permissions resolve
  238. if i.acc.UserExists(username) && !i.IsManaged(username) {
  239. //A local-only account with the same name: keep it untouched but the
  240. //origin decided the password, so just let the user in with local groups
  241. return auth.ForwardAuthResult{Decided: true, Accepted: true}
  242. }
  243. groups := i.localGroups(resp.Groups)
  244. if len(groups) == 0 {
  245. return auth.ForwardAuthResult{Decided: true, Accepted: false, Reason: "None of your permission groups exist on this node. Ask an administrator to create a matching group."}
  246. }
  247. if err := i.mirrorAccount(origin, username, passwordHash, groups); err != nil {
  248. return auth.ForwardAuthResult{Decided: true, Accepted: false, Reason: "Unable to create your account on this node: " + err.Error()}
  249. }
  250. return auth.ForwardAuthResult{Decided: true, Accepted: true}
  251. }
  252. // NotifyPasswordChanged forwards a password change of a replicated account
  253. // to the origin so it does not get reverted by the next sync.
  254. func (i *Manager) NotifyPasswordChanged(username string, passwordHash string) {
  255. origin, active := i.forwardingActive()
  256. if !active || !i.IsManaged(username) {
  257. return
  258. }
  259. go func() {
  260. ctx, cancel := context.WithTimeout(context.Background(), forwardAuthTimeout)
  261. defer cancel()
  262. err := i.m.Transport().DoJSON(ctx, origin, http.MethodPost, acn.BasePath+"/auth/setpassword", SetPasswordRequest{Username: username, PasswordHash: passwordHash}, nil)
  263. if err != nil {
  264. logger.PrintAndLog("Cluster", "Unable to forward password change of "+username+" to the identity origin: "+err.Error(), nil)
  265. }
  266. }()
  267. }
  268. /*
  269. Directory replication
  270. */
  271. // buildDirectory lists the accounts of this node (served when it is the origin).
  272. func (i *Manager) buildDirectory() Directory {
  273. dir := Directory{Origin: i.m.NodeID(), Users: []Account{}, Time: time.Now().Unix()}
  274. users := i.acc.ListUsers()
  275. sort.Strings(users)
  276. for _, u := range users {
  277. hash, err := i.acc.PasswordHash(u)
  278. if err != nil || hash == "" {
  279. continue
  280. }
  281. groups, _ := i.acc.Groups(u)
  282. if groups == nil {
  283. groups = []string{}
  284. }
  285. dir.Users = append(dir.Users, Account{Username: u, PasswordHash: hash, Groups: groups})
  286. }
  287. return dir
  288. }
  289. // applyDirectory mirrors the origin directory onto this node.
  290. func (i *Manager) applyDirectory(origin string, dir Directory) {
  291. seen := map[string]bool{}
  292. skipped := []string{}
  293. conflicts := []string{}
  294. for _, a := range dir.Users {
  295. if !validUsername(a.Username) || a.PasswordHash == "" {
  296. continue
  297. }
  298. if i.acc.UserExists(a.Username) && !i.IsManaged(a.Username) {
  299. conflicts = append(conflicts, a.Username)
  300. continue
  301. }
  302. groups := i.localGroups(a.Groups)
  303. if len(groups) == 0 {
  304. skipped = append(skipped, a.Username)
  305. continue
  306. }
  307. seen[a.Username] = true
  308. if err := i.mirrorAccount(origin, a.Username, a.PasswordHash, groups); err != nil {
  309. logger.PrintAndLog("Cluster", "Unable to mirror account "+a.Username+": "+err.Error(), nil)
  310. }
  311. }
  312. //Accounts we mirrored earlier that the origin no longer has
  313. i.mu.Lock()
  314. stale := []string{}
  315. for u := range i.managed {
  316. if !seen[u] {
  317. stale = append(stale, u)
  318. }
  319. }
  320. i.skipped = skipped
  321. i.conflicts = conflicts
  322. i.mu.Unlock()
  323. for _, u := range stale {
  324. if err := i.acc.DeleteUser(u); err == nil {
  325. logger.PrintAndLog("Cluster", "Removed replicated account "+u+" that no longer exists on the identity origin", nil)
  326. }
  327. i.unmarkManaged(u)
  328. }
  329. }
  330. // SyncNow pulls the origin directory once. It is a no-op on the origin
  331. // itself or when forward authentication is disabled.
  332. func (i *Manager) SyncNow() error {
  333. origin, active := i.forwardingActive()
  334. if !active {
  335. return nil
  336. }
  337. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  338. defer cancel()
  339. var dir Directory
  340. err := i.m.Transport().DoJSON(ctx, origin, http.MethodGet, acn.BasePath+"/auth/directory", nil, &dir)
  341. i.mu.Lock()
  342. i.lastSync = time.Now().Unix()
  343. if err != nil {
  344. i.lastSyncErr = err.Error()
  345. } else {
  346. i.lastSyncErr = ""
  347. }
  348. i.mu.Unlock()
  349. if err != nil {
  350. return err
  351. }
  352. if dir.Origin != origin {
  353. return errors.New("directory came from an unexpected node")
  354. }
  355. i.applyDirectory(origin, dir)
  356. return nil
  357. }
  358. // TriggerSync asks the background loop to sync soon.
  359. func (i *Manager) TriggerSync() {
  360. select {
  361. case i.trigger <- struct{}{}:
  362. default:
  363. }
  364. }
  365. func (i *Manager) loop() {
  366. ticker := time.NewTicker(i.interval)
  367. defer ticker.Stop()
  368. //Initial sync shortly after boot so members are warm before first login
  369. timer := time.NewTimer(3 * time.Second)
  370. defer timer.Stop()
  371. for {
  372. select {
  373. case <-i.stop:
  374. return
  375. case <-timer.C:
  376. i.SyncNow()
  377. case <-ticker.C:
  378. i.SyncNow()
  379. case <-i.trigger:
  380. i.SyncNow()
  381. }
  382. }
  383. }
  384. /*
  385. Status
  386. */
  387. // Status is the identity picture for the settings UI.
  388. type Status struct {
  389. Enabled bool `json:"enabled"`
  390. Origin string `json:"origin"`
  391. OriginName string `json:"originName"`
  392. IsOrigin bool `json:"isOrigin"`
  393. ManagedAccounts int `json:"managedAccounts"`
  394. LocalAccounts int `json:"localAccounts"`
  395. Skipped []string `json:"skipped"`
  396. Conflicts []string `json:"conflicts"`
  397. LastSync int64 `json:"lastSync"`
  398. LastSyncError string `json:"lastSyncError"`
  399. LastForward int64 `json:"lastForward"`
  400. LastForwardError string `json:"lastForwardError"`
  401. }
  402. // Status builds the current identity status.
  403. func (i *Manager) Status() Status {
  404. origin, self := i.Origin()
  405. i.mu.Lock()
  406. defer i.mu.Unlock()
  407. st := Status{
  408. Enabled: origin != "",
  409. Origin: origin,
  410. IsOrigin: self,
  411. ManagedAccounts: len(i.managed),
  412. LocalAccounts: len(i.acc.ListUsers()),
  413. Skipped: append([]string{}, i.skipped...),
  414. Conflicts: append([]string{}, i.conflicts...),
  415. LastSync: i.lastSync,
  416. LastSyncError: i.lastSyncErr,
  417. LastForward: i.lastForward,
  418. LastForwardError: i.lastForwardE,
  419. }
  420. if origin != "" {
  421. st.OriginName = i.m.NodeName(origin)
  422. }
  423. return st
  424. }
  425. func validUsername(u string) bool {
  426. if u == "" || len(u) > 128 {
  427. return false
  428. }
  429. return !strings.ContainsAny(u, "/\\\x00")
  430. }