package identity import ( "context" "errors" "fmt" "sort" "time" "tug/internal/platform/backoff" "tug/internal/service/password" "tug/internal/storage" ) const VerifyBudget = 10 * time.Second var ( ErrRejected = errors.New("identity: credentials rejected") ErrUnavailable = errors.New("identity: verification is busy") ErrNoUser = errors.New("identity: user does not exist or is disabled") ) type Claims struct { UserID string Username string DisplayName string Groups []string } type Service struct { store *storage.Store primaryGroup string lockout *backoff.Backoff path string observe func(path, result string) } func New(store *storage.Store, primaryGroup string) *Service { return NewWithLockout(store, primaryGroup, nil) } func NewWithLockout(store *storage.Store, primaryGroup string, lockout *backoff.Backoff) *Service { if lockout == nil { lockout = backoff.New(time.Now, backoff.Entries) } return &Service{store: store, primaryGroup: primaryGroup, lockout: lockout, observe: discard} } func (s *Service) Lockout() *backoff.Backoff { return s.lockout } func (s *Service) Authenticate(ctx context.Context, username, pw string) (storage.User, error) { key, err := storage.NormalizeUsername(username) if err != nil { return s.observed(s.authenticate(ctx, username, pw)) } if s.lockout.Locked(key) { s.observe(s.path, checkLocked) return storage.User{}, ErrRejected } user, err := s.authenticate(ctx, username, pw) switch { case err == nil: s.lockout.Reset(key) case errors.Is(err, ErrRejected): s.lockout.Fail(key) } return s.observed(user, err) } func (s *Service) authenticate(ctx context.Context, username, pw string) (storage.User, error) { ctx, cancel := context.WithTimeout(ctx, VerifyBudget) defer cancel() user, err := s.store.UserByUsername(ctx, username) switch { case errors.Is(err, storage.ErrNotFound), errors.Is(err, storage.ErrInvalid), err == nil && user.Disabled: return storage.User{}, verdict(false, password.VerifyAbsent(ctx, pw)) case errors.Is(err, storage.ErrBusy), errors.Is(err, storage.ErrLocked), errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): return storage.User{}, fmt.Errorf("%w: %v", ErrUnavailable, err) case err != nil: return storage.User{}, err } ok, err := password.Verify(ctx, user.PasswordHash, pw) if err := verdict(ok, err); errors.Is(err, ErrRejected) { return storage.User{}, &WrongPassword{UserID: user.ID} } else if err != nil { return storage.User{}, err } return user, nil } func verdict(ok bool, err error) error { switch { case errors.Is(err, password.ErrBusy): return fmt.Errorf("%w: %w", ErrUnavailable, err) case err != nil, !ok: return ErrRejected } return nil } func (s *Service) Claims(ctx context.Context, userID string, wantGroups bool) (Claims, error) { user, err := s.store.UserByID(ctx, userID) if errors.Is(err, storage.ErrNotFound) || errors.Is(err, storage.ErrInvalid) || err == nil && user.Disabled { return Claims{}, ErrNoUser } if err != nil { return Claims{}, err } claims := Claims{ UserID: user.ID, Username: user.Username, DisplayName: user.DisplayName, } if !wantGroups { return claims, nil } groups, err := s.store.GroupsForUser(ctx, user.ID) if err != nil { return Claims{}, err } names := map[string]bool{} for _, g := range groups { names[g.Name] = true } if s.primaryGroup != "" && !names[s.primaryGroup] { if _, err := s.store.GroupByName(ctx, s.primaryGroup); err == nil { names[s.primaryGroup] = true } else if !errors.Is(err, storage.ErrNotFound) { return Claims{}, err } } claims.Groups = make([]string, 0, len(names)) for name := range names { claims.Groups = append(claims.Groups, name) } sort.Strings(claims.Groups) return claims, nil }