Raw
  1package identity
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"sort"
  8	"time"
  9
 10	"tug/internal/platform/backoff"
 11	"tug/internal/service/password"
 12	"tug/internal/storage"
 13)
 14
 15const VerifyBudget = 10 * time.Second
 16
 17var (
 18	ErrRejected    = errors.New("identity: credentials rejected")
 19	ErrUnavailable = errors.New("identity: verification is busy")
 20	ErrNoUser      = errors.New("identity: user does not exist or is disabled")
 21)
 22
 23type Claims struct {
 24	UserID      string
 25	Username    string
 26	DisplayName string
 27	Groups      []string
 28}
 29
 30type Service struct {
 31	store        *storage.Store
 32	primaryGroup string
 33	lockout      *backoff.Backoff
 34	path         string
 35	observe      func(path, result string)
 36}
 37
 38func New(store *storage.Store, primaryGroup string) *Service {
 39	return NewWithLockout(store, primaryGroup, nil)
 40}
 41
 42func NewWithLockout(store *storage.Store, primaryGroup string, lockout *backoff.Backoff) *Service {
 43	if lockout == nil {
 44		lockout = backoff.New(time.Now, backoff.Entries)
 45	}
 46	return &Service{store: store, primaryGroup: primaryGroup, lockout: lockout, observe: discard}
 47}
 48
 49func (s *Service) Lockout() *backoff.Backoff {
 50	return s.lockout
 51}
 52
 53func (s *Service) Authenticate(ctx context.Context, username, pw string) (storage.User, error) {
 54	key, err := storage.NormalizeUsername(username)
 55	if err != nil {
 56		return s.observed(s.authenticate(ctx, username, pw))
 57	}
 58	if s.lockout.Locked(key) {
 59		s.observe(s.path, checkLocked)
 60		return storage.User{}, ErrRejected
 61	}
 62	user, err := s.authenticate(ctx, username, pw)
 63	switch {
 64	case err == nil:
 65		s.lockout.Reset(key)
 66	case errors.Is(err, ErrRejected):
 67		s.lockout.Fail(key)
 68	}
 69	return s.observed(user, err)
 70}
 71
 72func (s *Service) authenticate(ctx context.Context, username, pw string) (storage.User, error) {
 73	ctx, cancel := context.WithTimeout(ctx, VerifyBudget)
 74	defer cancel()
 75	user, err := s.store.UserByUsername(ctx, username)
 76	switch {
 77	case errors.Is(err, storage.ErrNotFound), errors.Is(err, storage.ErrInvalid), err == nil && user.Disabled:
 78		return storage.User{}, verdict(false, password.VerifyAbsent(ctx, pw))
 79	case errors.Is(err, storage.ErrBusy), errors.Is(err, storage.ErrLocked), errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
 80		return storage.User{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
 81	case err != nil:
 82		return storage.User{}, err
 83	}
 84	ok, err := password.Verify(ctx, user.PasswordHash, pw)
 85	if err := verdict(ok, err); errors.Is(err, ErrRejected) {
 86		return storage.User{}, &WrongPassword{UserID: user.ID}
 87	} else if err != nil {
 88		return storage.User{}, err
 89	}
 90	return user, nil
 91}
 92
 93func verdict(ok bool, err error) error {
 94	switch {
 95	case errors.Is(err, password.ErrBusy):
 96		return fmt.Errorf("%w: %w", ErrUnavailable, err)
 97	case err != nil, !ok:
 98		return ErrRejected
 99	}
100	return nil
101}
102
103func (s *Service) Claims(ctx context.Context, userID string, wantGroups bool) (Claims, error) {
104	user, err := s.store.UserByID(ctx, userID)
105	if errors.Is(err, storage.ErrNotFound) || errors.Is(err, storage.ErrInvalid) || err == nil && user.Disabled {
106		return Claims{}, ErrNoUser
107	}
108	if err != nil {
109		return Claims{}, err
110	}
111	claims := Claims{
112		UserID:      user.ID,
113		Username:    user.Username,
114		DisplayName: user.DisplayName,
115	}
116	if !wantGroups {
117		return claims, nil
118	}
119	groups, err := s.store.GroupsForUser(ctx, user.ID)
120	if err != nil {
121		return Claims{}, err
122	}
123	names := map[string]bool{}
124	for _, g := range groups {
125		names[g.Name] = true
126	}
127	if s.primaryGroup != "" && !names[s.primaryGroup] {
128		if _, err := s.store.GroupByName(ctx, s.primaryGroup); err == nil {
129			names[s.primaryGroup] = true
130		} else if !errors.Is(err, storage.ErrNotFound) {
131			return Claims{}, err
132		}
133	}
134	claims.Groups = make([]string, 0, len(names))
135	for name := range names {
136		claims.Groups = append(claims.Groups, name)
137	}
138	sort.Strings(claims.Groups)
139	return claims, nil
140}