1package signin
2
3import (
4 "context"
5 "errors"
6 "net/netip"
7 "slices"
8 "time"
9
10 "tug/internal/service/identity"
11 "tug/internal/service/password"
12 "tug/internal/service/session"
13 "tug/internal/storage"
14 "tug/internal/transport/web"
15)
16
17type account struct {
18 s *session.Service
19 id *identity.Service
20 host string
21 adminURL string
22 primary string
23 google bool
24 portdistrict bool
25}
26
27var _ web.AccountBackend = account{}
28
29func (s *Signin) Account() web.AccountBackend {
30 return s.account
31}
32
33func (s *Signin) Ender() web.SessionEnder {
34 return s.ender
35}
36
37func (a account) principal(p web.Principal) session.Principal {
38 return session.Principal{SessionID: p.SessionID, UserID: p.UserID, Username: string(p.Username), Host: a.host}
39}
40
41func (a account) Profile(ctx context.Context, p web.Principal) (web.AccountProfile, error) {
42 c, err := a.id.Claims(ctx, p.UserID, true)
43 if err != nil {
44 return web.AccountProfile{}, accountError(err)
45 }
46 prof := web.AccountProfile{
47 Username: c.Username,
48 DisplayName: c.DisplayName,
49 Groups: c.Groups,
50 GroupCount: len(c.Groups),
51 Google: a.google,
52 Portdistrict: a.portdistrict,
53 }
54 if a.google {
55 link, err := a.s.GoogleLink(ctx, p.UserID)
56 if err != nil {
57 return web.AccountProfile{}, accountError(err)
58 }
59 if link != nil {
60 prof.GoogleLink = &web.AccountGoogleLink{LinkedAt: link.LinkedAt}
61 }
62 }
63 keys, err := a.s.SSHKeys(ctx, p.UserID)
64 if err != nil {
65 return web.AccountProfile{}, accountError(err)
66 }
67 prof.SSHKeys = webSSHKeys(keys)
68 if a.primary != "" && slices.Contains(c.Groups, a.primary) {
69 prof.Primary = a.primary
70 }
71 if p.Admin && a.adminURL != "" {
72 prof.AdminURL = a.adminURL
73 }
74 return prof, nil
75}
76
77func (a account) ChangePassword(ctx context.Context, p web.Principal, current, next string) error {
78 _, err := a.s.ChangePassword(ctx, a.principal(p), current, next)
79 return accountError(err)
80}
81
82func (a account) Sessions(ctx context.Context, p web.Principal, page int) (web.AccountSessions, error) {
83 rows, err := a.s.List(ctx, p.UserID)
84 if err != nil {
85 return web.AccountSessions{}, accountError(err)
86 }
87 var browser string
88 for _, r := range rows {
89 if r.ID == p.SessionID {
90 browser = r.Browser
91 }
92 }
93 out := web.AccountSessions{Total: len(rows)}
94 start := (page - 1) * web.AccountSessionsPerPage
95 if page < 1 || start >= len(rows) {
96 return out, nil
97 }
98 for _, r := range rows[start:min(start+web.AccountSessionsPerPage, len(rows))] {
99 out.Sessions = append(out.Sessions, web.AccountSession{
100 ID: r.ID,
101 Host: r.AppDomain,
102 Created: r.CreatedAt,
103 Expires: r.ExpiresAt,
104 ThisSession: r.ID == p.SessionID,
105 ThisBrowser: browser != "" && r.Browser == browser,
106 ClientID: r.ClientID,
107 UserAgent: r.UserAgent,
108 Address: addressText(r.Address),
109 LastSeen: lastSeen(r.LastSeenAt),
110 })
111 }
112 return out, nil
113}
114
115func (a account) RevokeSession(ctx context.Context, p web.Principal, id string) error {
116 return accountError(a.s.RevokeOwn(ctx, a.principal(p), id))
117}
118
119func (a account) RevokeOtherSessions(ctx context.Context, p web.Principal) (int, error) {
120 n, err := a.s.RevokeOthers(ctx, a.principal(p))
121 return int(n), accountError(err)
122}
123
124func (a account) SignOutBrowser(ctx context.Context, p web.Principal) (int, error) {
125 revoked, err := a.s.RevokeBrowser(ctx, a.principal(p))
126 if errors.Is(err, session.ErrNoSession) {
127 return 0, nil
128 }
129 return int(revoked.Sessions), accountError(err)
130}
131
132func (a account) UpdateDisplayName(ctx context.Context, p web.Principal, name string) error {
133 return accountError(a.s.UpdateDisplayName(ctx, a.principal(p), name))
134}
135
136var accountProblems = []struct {
137 err error
138 want error
139}{
140 {session.ErrPasswordRejected, web.AccountPasswordWrong},
141 {password.ErrTooShort, web.AccountPasswordShort},
142 {password.ErrTooLong, web.AccountPasswordLong},
143 {session.ErrPasswordChanged, web.AccountPasswordChanged},
144 {session.ErrDisplayNameInvalid, web.AccountDisplayNameInvalid},
145 {session.ErrNoSuchSession, web.ErrAccountNotFound},
146 {session.ErrNoSuchSSHKey, web.ErrAccountNotFound},
147 {session.ErrUnavailable, web.AccountBusy},
148 {session.ErrNoSession, web.ErrNoSession},
149}
150
151func (a account) SSHKeys(ctx context.Context, p web.Principal) ([]web.SSHKey, error) {
152 keys, err := a.s.SSHKeys(ctx, p.UserID)
153 if err != nil {
154 return nil, accountError(err)
155 }
156 return webSSHKeys(keys), nil
157}
158
159func (a account) AddSSHKey(ctx context.Context, p web.Principal, key, label string) (web.SSHKey, error) {
160 added, err := a.s.AddSSHKey(ctx, a.principal(p), key, label)
161 if err != nil {
162 return web.SSHKey{}, accountError(err)
163 }
164 return webSSHKey(added), nil
165}
166
167func (a account) RemoveSSHKey(ctx context.Context, p web.Principal, keyID string) (web.SSHKey, error) {
168 removed, err := a.s.RemoveSSHKey(ctx, a.principal(p), keyID)
169 if err != nil {
170 return web.SSHKey{}, accountError(err)
171 }
172 return webSSHKey(removed), nil
173}
174
175func webSSHKey(k storage.SSHKey) web.SSHKey {
176 return web.SSHKey{ID: k.ID, Fingerprint: k.Fingerprint, Type: k.Type, Label: k.Label, AddedAt: k.AddedAt}
177}
178
179func webSSHKeys(keys []storage.SSHKey) []web.SSHKey {
180 var out []web.SSHKey
181 for _, k := range keys {
182 out = append(out, webSSHKey(k))
183 }
184 return out
185}
186
187func accountError(err error) error {
188 if err == nil {
189 return nil
190 }
191 for _, m := range accountProblems {
192 if errors.Is(err, m.err) {
193 return m.want
194 }
195 }
196 return err
197}
198
199func addressText(a netip.Addr) string {
200 if !a.IsValid() {
201 return ""
202 }
203 return a.String()
204}
205
206func lastSeen(at *time.Time) time.Time {
207 if at == nil {
208 return time.Time{}
209 }
210 return *at
211}