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}