| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473 |
- package identity
- /*
- ArozOS Cluster - identity service (AID)
- One member of the cluster can be elected the "identity origin" (SSO
- owner). Every other member then:
- 1. forwards login attempts to the origin (forward auth): the origin
- checks the SHA-512 password hash against its own account table and
- answers with the user's groups. The member mirrors the account
- locally (hash + groups that exist on the member) and opens a normal
- local session.
- 2. keeps a replicated copy of the origin's account directory so users
- can still log in when the origin is unreachable (fallback).
- 3. forwards password changes of replicated accounts back to the origin.
- Accounts that exist only on a member (never replicated) are left alone,
- so every node still works standalone. Group membership is mapped by group
- NAME: only groups that exist locally are applied, because groups carry
- node-specific storage and module settings.
- */
- import (
- "context"
- "errors"
- "net/http"
- "sort"
- "strings"
- "sync"
- "time"
- "imuslab.com/arozos/mod/auth"
- "imuslab.com/arozos/mod/cluster/acn"
- "imuslab.com/arozos/mod/cluster/membership"
- "imuslab.com/arozos/mod/database"
- "imuslab.com/arozos/mod/info/logger"
- )
- const (
- // DefaultSyncInterval is how often members pull the origin directory.
- DefaultSyncInterval = 5 * time.Minute
- forwardAuthTimeout = 12 * time.Second
- keyManagedPrefix = "managed/"
- )
- // AccountStore is what the identity service needs from the host's account
- // system. The core adapts the auth agent and permission handler to it.
- type AccountStore interface {
- UserExists(username string) bool
- PasswordHash(username string) (string, error)
- SetPasswordHash(username string, hash string) error
- Groups(username string) ([]string, error)
- SetGroups(username string, groups []string) error
- ListUsers() []string
- DeleteUser(username string) error
- GroupExists(group string) bool
- }
- // Account is one replicated user entry.
- type Account struct {
- Username string `json:"username"`
- PasswordHash string `json:"passwordHash"`
- Groups []string `json:"groups"`
- }
- // Directory is the origin's full account list.
- type Directory struct {
- Origin string `json:"origin"`
- Users []Account `json:"users"`
- Time int64 `json:"time"`
- }
- // Wire payloads
- type VerifyRequest struct {
- Username string `json:"username"`
- PasswordHash string `json:"passwordHash"`
- }
- type VerifyResponse struct {
- OK bool `json:"ok"`
- Reason string `json:"reason,omitempty"`
- Groups []string `json:"groups,omitempty"`
- }
- type SetPasswordRequest struct {
- Username string `json:"username"`
- PasswordHash string `json:"passwordHash"`
- }
- // Option configures the identity service.
- type Option struct {
- Membership *membership.Manager
- Accounts AccountStore
- SyncInterval time.Duration
- }
- // Manager is the identity service of this node.
- type Manager struct {
- m *membership.Manager
- acc AccountStore
- db *database.Database
- mu sync.Mutex
- managed map[string]bool //accounts on this node that mirror the origin
- lastSync int64
- lastSyncErr string
- lastForward int64
- lastForwardE string
- skipped []string //directory users without a matching local group
- conflicts []string //directory users shadowed by a local-only account
- interval time.Duration
- stop chan struct{}
- trigger chan struct{}
- once sync.Once
- }
- // New creates the identity service and registers its ACN endpoints.
- func New(opt Option) (*Manager, error) {
- if opt.Membership == nil || opt.Accounts == nil {
- return nil, errors.New("membership manager and account store are required")
- }
- if opt.SyncInterval <= 0 {
- opt.SyncInterval = DefaultSyncInterval
- }
- i := &Manager{
- m: opt.Membership,
- acc: opt.Accounts,
- db: opt.Membership.DB(),
- managed: map[string]bool{},
- interval: opt.SyncInterval,
- stop: make(chan struct{}),
- trigger: make(chan struct{}, 1),
- }
- i.db.NewTable(membership.TableIdentity)
- i.loadManaged()
- i.registerACNHandlers()
- prev := i.m.OnClusterChange
- i.m.OnClusterChange = func() {
- if prev != nil {
- prev()
- }
- i.TriggerSync()
- }
- go i.loop()
- return i, nil
- }
- // Close stops the background sync.
- func (i *Manager) Close() {
- i.once.Do(func() { close(i.stop) })
- }
- func (i *Manager) loadManaged() {
- entries, err := i.db.ListTable(membership.TableIdentity)
- if err != nil {
- return
- }
- i.mu.Lock()
- defer i.mu.Unlock()
- for _, kv := range entries {
- k := string(kv[0])
- if strings.HasPrefix(k, keyManagedPrefix) {
- i.managed[strings.TrimPrefix(k, keyManagedPrefix)] = true
- }
- }
- }
- func (i *Manager) markManaged(username string, origin string) {
- i.mu.Lock()
- i.managed[username] = true
- i.mu.Unlock()
- i.db.Write(membership.TableIdentity, keyManagedPrefix+username, origin)
- }
- func (i *Manager) unmarkManaged(username string) {
- i.mu.Lock()
- delete(i.managed, username)
- i.mu.Unlock()
- i.db.Delete(membership.TableIdentity, keyManagedPrefix+username)
- }
- // IsManaged reports whether an account on this node mirrors the origin.
- func (i *Manager) IsManaged(username string) bool {
- i.mu.Lock()
- defer i.mu.Unlock()
- return i.managed[username]
- }
- // Origin returns the identity origin node and whether it is this node.
- func (i *Manager) Origin() (nodeID string, isSelf bool) {
- origin := i.m.IdentityOrigin()
- return origin, origin != "" && origin == i.m.NodeID()
- }
- // forwardingActive is true when logins should be checked on another node.
- func (i *Manager) forwardingActive() (string, bool) {
- origin, self := i.Origin()
- if origin == "" || self || !i.m.InCluster() {
- return "", false
- }
- return origin, true
- }
- // localGroups keeps only the groups that exist on this node.
- func (i *Manager) localGroups(groups []string) []string {
- out := []string{}
- for _, g := range groups {
- if g != "" && i.acc.GroupExists(g) {
- out = append(out, g)
- }
- }
- return out
- }
- // mirrorAccount creates or updates the local copy of an origin account.
- func (i *Manager) mirrorAccount(origin string, username string, hash string, groups []string) error {
- if err := i.acc.SetPasswordHash(username, hash); err != nil {
- return err
- }
- if err := i.acc.SetGroups(username, groups); err != nil {
- return err
- }
- i.markManaged(username, origin)
- return nil
- }
- /*
- Forward authentication
- */
- // ForwardAuth is installed as the auth agent's ForwardAuth hook.
- func (i *Manager) ForwardAuth(username string, passwordHash string) auth.ForwardAuthResult {
- origin, active := i.forwardingActive()
- if !active {
- return auth.ForwardAuthResult{}
- }
- if !validUsername(username) {
- return auth.ForwardAuthResult{Decided: true, Accepted: false, Reason: "Invalid username or password"}
- }
- ctx, cancel := context.WithTimeout(context.Background(), forwardAuthTimeout)
- defer cancel()
- var resp VerifyResponse
- err := i.m.Transport().DoJSON(ctx, origin, http.MethodPost, acn.BasePath+"/auth/verify", VerifyRequest{Username: username, PasswordHash: passwordHash}, &resp)
- i.mu.Lock()
- i.lastForward = time.Now().Unix()
- if err != nil {
- i.lastForwardE = err.Error()
- } else {
- i.lastForwardE = ""
- }
- i.mu.Unlock()
- if err != nil {
- //Origin unreachable: let the local (replicated) table decide
- logger.PrintAndLog("Cluster", "Identity origin unreachable, using local accounts: "+err.Error(), nil)
- return auth.ForwardAuthResult{}
- }
- if !resp.OK {
- reason := resp.Reason
- if reason == "" {
- reason = "Invalid username or password"
- }
- return auth.ForwardAuthResult{Decided: true, Accepted: false, Reason: reason}
- }
- //Mirror the account locally so the session and permissions resolve
- if i.acc.UserExists(username) && !i.IsManaged(username) {
- //A local-only account with the same name: keep it untouched but the
- //origin decided the password, so just let the user in with local groups
- return auth.ForwardAuthResult{Decided: true, Accepted: true}
- }
- groups := i.localGroups(resp.Groups)
- if len(groups) == 0 {
- 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."}
- }
- if err := i.mirrorAccount(origin, username, passwordHash, groups); err != nil {
- return auth.ForwardAuthResult{Decided: true, Accepted: false, Reason: "Unable to create your account on this node: " + err.Error()}
- }
- return auth.ForwardAuthResult{Decided: true, Accepted: true}
- }
- // NotifyPasswordChanged forwards a password change of a replicated account
- // to the origin so it does not get reverted by the next sync.
- func (i *Manager) NotifyPasswordChanged(username string, passwordHash string) {
- origin, active := i.forwardingActive()
- if !active || !i.IsManaged(username) {
- return
- }
- go func() {
- ctx, cancel := context.WithTimeout(context.Background(), forwardAuthTimeout)
- defer cancel()
- err := i.m.Transport().DoJSON(ctx, origin, http.MethodPost, acn.BasePath+"/auth/setpassword", SetPasswordRequest{Username: username, PasswordHash: passwordHash}, nil)
- if err != nil {
- logger.PrintAndLog("Cluster", "Unable to forward password change of "+username+" to the identity origin: "+err.Error(), nil)
- }
- }()
- }
- /*
- Directory replication
- */
- // buildDirectory lists the accounts of this node (served when it is the origin).
- func (i *Manager) buildDirectory() Directory {
- dir := Directory{Origin: i.m.NodeID(), Users: []Account{}, Time: time.Now().Unix()}
- users := i.acc.ListUsers()
- sort.Strings(users)
- for _, u := range users {
- hash, err := i.acc.PasswordHash(u)
- if err != nil || hash == "" {
- continue
- }
- groups, _ := i.acc.Groups(u)
- if groups == nil {
- groups = []string{}
- }
- dir.Users = append(dir.Users, Account{Username: u, PasswordHash: hash, Groups: groups})
- }
- return dir
- }
- // applyDirectory mirrors the origin directory onto this node.
- func (i *Manager) applyDirectory(origin string, dir Directory) {
- seen := map[string]bool{}
- skipped := []string{}
- conflicts := []string{}
- for _, a := range dir.Users {
- if !validUsername(a.Username) || a.PasswordHash == "" {
- continue
- }
- if i.acc.UserExists(a.Username) && !i.IsManaged(a.Username) {
- conflicts = append(conflicts, a.Username)
- continue
- }
- groups := i.localGroups(a.Groups)
- if len(groups) == 0 {
- skipped = append(skipped, a.Username)
- continue
- }
- seen[a.Username] = true
- if err := i.mirrorAccount(origin, a.Username, a.PasswordHash, groups); err != nil {
- logger.PrintAndLog("Cluster", "Unable to mirror account "+a.Username+": "+err.Error(), nil)
- }
- }
- //Accounts we mirrored earlier that the origin no longer has
- i.mu.Lock()
- stale := []string{}
- for u := range i.managed {
- if !seen[u] {
- stale = append(stale, u)
- }
- }
- i.skipped = skipped
- i.conflicts = conflicts
- i.mu.Unlock()
- for _, u := range stale {
- if err := i.acc.DeleteUser(u); err == nil {
- logger.PrintAndLog("Cluster", "Removed replicated account "+u+" that no longer exists on the identity origin", nil)
- }
- i.unmarkManaged(u)
- }
- }
- // SyncNow pulls the origin directory once. It is a no-op on the origin
- // itself or when forward authentication is disabled.
- func (i *Manager) SyncNow() error {
- origin, active := i.forwardingActive()
- if !active {
- return nil
- }
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- var dir Directory
- err := i.m.Transport().DoJSON(ctx, origin, http.MethodGet, acn.BasePath+"/auth/directory", nil, &dir)
- i.mu.Lock()
- i.lastSync = time.Now().Unix()
- if err != nil {
- i.lastSyncErr = err.Error()
- } else {
- i.lastSyncErr = ""
- }
- i.mu.Unlock()
- if err != nil {
- return err
- }
- if dir.Origin != origin {
- return errors.New("directory came from an unexpected node")
- }
- i.applyDirectory(origin, dir)
- return nil
- }
- // TriggerSync asks the background loop to sync soon.
- func (i *Manager) TriggerSync() {
- select {
- case i.trigger <- struct{}{}:
- default:
- }
- }
- func (i *Manager) loop() {
- ticker := time.NewTicker(i.interval)
- defer ticker.Stop()
- //Initial sync shortly after boot so members are warm before first login
- timer := time.NewTimer(3 * time.Second)
- defer timer.Stop()
- for {
- select {
- case <-i.stop:
- return
- case <-timer.C:
- i.SyncNow()
- case <-ticker.C:
- i.SyncNow()
- case <-i.trigger:
- i.SyncNow()
- }
- }
- }
- /*
- Status
- */
- // Status is the identity picture for the settings UI.
- type Status struct {
- Enabled bool `json:"enabled"`
- Origin string `json:"origin"`
- OriginName string `json:"originName"`
- IsOrigin bool `json:"isOrigin"`
- ManagedAccounts int `json:"managedAccounts"`
- LocalAccounts int `json:"localAccounts"`
- Skipped []string `json:"skipped"`
- Conflicts []string `json:"conflicts"`
- LastSync int64 `json:"lastSync"`
- LastSyncError string `json:"lastSyncError"`
- LastForward int64 `json:"lastForward"`
- LastForwardError string `json:"lastForwardError"`
- }
- // Status builds the current identity status.
- func (i *Manager) Status() Status {
- origin, self := i.Origin()
- i.mu.Lock()
- defer i.mu.Unlock()
- st := Status{
- Enabled: origin != "",
- Origin: origin,
- IsOrigin: self,
- ManagedAccounts: len(i.managed),
- LocalAccounts: len(i.acc.ListUsers()),
- Skipped: append([]string{}, i.skipped...),
- Conflicts: append([]string{}, i.conflicts...),
- LastSync: i.lastSync,
- LastSyncError: i.lastSyncErr,
- LastForward: i.lastForward,
- LastForwardError: i.lastForwardE,
- }
- if origin != "" {
- st.OriginName = i.m.NodeName(origin)
- }
- return st
- }
- func validUsername(u string) bool {
- if u == "" || len(u) > 128 {
- return false
- }
- return !strings.ContainsAny(u, "/\\\x00")
- }
|