rin
Публичный Open-source lightweight git hosting.
device.go · 195 строк · 5.7 KB
Raw
1 package auth
2
3 import (
4 "context"
5 "crypto/rand"
6 "database/sql"
7 "errors"
8 "fmt"
9 "math/big"
10 "strings"
11 "time"
12 )
13
14 const (
15 DeviceCodeTTL = 15 * time.Minute
16 DeviceCodeInterval = 5 // seconds the client should wait between polls
17 )
18
19 // Status values for a device authorization grant.
20 const (
21 DeviceStatusPending = "pending"
22 DeviceStatusApproved = "approved"
23 DeviceStatusDenied = "denied"
24 )
25
26 var ErrDeviceCodeNotFound = errors.New("device code not found")
27
28 // userCodeAlphabet avoids characters that are easy to confuse (0/O, 1/I/L).
29 const userCodeAlphabet = "BCDFGHJKLMNPQRSTVWXZ23456789"
30
31 type DeviceCode struct {
32 Code string
33 UserCode string
34 ClientID string
35 UserID *int64
36 Status string
37 ExpiresAt time.Time
38 }
39
40 func generateUserCode() (string, error) {
41 letters := make([]byte, 8)
42 for i := range letters {
43 n, err := rand.Int(rand.Reader, big.NewInt(int64(len(userCodeAlphabet))))
44 if err != nil {
45 return "", err
46 }
47 letters[i] = userCodeAlphabet[n.Int64()]
48 }
49 return string(letters[:4]) + "-" + string(letters[4:]), nil
50 }
51
52 // NormalizeUserCode uppercases the entered code and restores the XXXX-XXXX shape.
53 func NormalizeUserCode(code string) string {
54 code = strings.ToUpper(strings.TrimSpace(code))
55 code = strings.ReplaceAll(code, " ", "")
56 code = strings.ReplaceAll(code, "-", "")
57 if len(code) != 8 {
58 return code
59 }
60 return code[:4] + "-" + code[4:]
61 }
62
63 // CreateDeviceCode starts a device authorization grant (RFC 8628).
64 func (s *Service) CreateDeviceCode(ctx context.Context, clientID string) (*DeviceCode, error) {
65 clientID = strings.TrimSpace(clientID)
66 if len(clientID) > 120 {
67 clientID = clientID[:120]
68 }
69
70 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
71 defer cancel()
72
73 for attempt := 0; attempt < 5; attempt++ {
74 deviceCode, err := randomHex(24)
75 if err != nil {
76 return nil, fmt.Errorf("generate device code: %w", err)
77 }
78 userCode, err := generateUserCode()
79 if err != nil {
80 return nil, fmt.Errorf("generate user code: %w", err)
81 }
82
83 expiresAt := time.Now().UTC().Add(DeviceCodeTTL)
84 _, err = s.db.ExecContext(cctx, `
85 INSERT INTO device_codes (device_code, user_code, client_id, status, created_at, expires_at)
86 VALUES (?, ?, ?, ?, ?, ?)
87 `, deviceCode, userCode, clientID, DeviceStatusPending, time.Now().UTC(), expiresAt)
88 if err != nil {
89 if strings.Contains(strings.ToLower(err.Error()), "unique") {
90 continue
91 }
92 return nil, fmt.Errorf("insert device code: %w", err)
93 }
94 return &DeviceCode{Code: deviceCode, UserCode: userCode, ClientID: clientID, Status: DeviceStatusPending, ExpiresAt: expiresAt}, nil
95 }
96 return nil, errors.New("could not allocate a unique user code")
97 }
98
99 // GetDeviceCodeByUserCode looks up a grant by the human-friendly code.
100 func (s *Service) GetDeviceCodeByUserCode(ctx context.Context, userCode string) (*DeviceCode, error) {
101 userCode = NormalizeUserCode(userCode)
102 if userCode == "" {
103 return nil, ErrDeviceCodeNotFound
104 }
105
106 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
107 defer cancel()
108
109 var dc DeviceCode
110 var userID sql.NullInt64
111 var expiresAt time.Time
112 err := s.db.QueryRowContext(cctx, `
113 SELECT device_code, user_code, client_id, user_id, status, expires_at
114 FROM device_codes WHERE user_code = ?
115 `, userCode).Scan(&dc.Code, &dc.UserCode, &dc.ClientID, &userID, &dc.Status, &expiresAt)
116 if err != nil {
117 if errors.Is(err, sql.ErrNoRows) {
118 return nil, ErrDeviceCodeNotFound
119 }
120 return nil, fmt.Errorf("get device code: %w", err)
121 }
122 if userID.Valid {
123 dc.UserID = &userID.Int64
124 }
125 dc.ExpiresAt = expiresAt
126 return &dc, nil
127 }
128
129 // SetDeviceCodeStatus approves or denies a pending grant.
130 func (s *Service) SetDeviceCodeStatus(ctx context.Context, userCode string, userID int64, status string) error {
131 if status != DeviceStatusApproved && status != DeviceStatusDenied {
132 return errors.New("invalid device code status")
133 }
134
135 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
136 defer cancel()
137
138 res, err := s.db.ExecContext(cctx, `
139 UPDATE device_codes
140 SET status = ?, user_id = ?
141 WHERE user_code = ? AND status = ? AND expires_at > ?
142 `, status, userID, NormalizeUserCode(userCode), DeviceStatusPending, time.Now().UTC())
143 if err != nil {
144 return fmt.Errorf("update device code: %w", err)
145 }
146 if n, _ := res.RowsAffected(); n == 0 {
147 return ErrDeviceCodeNotFound
148 }
149 return nil
150 }
151
152 // ConsumeDeviceCode marks an approved grant as exchanged so the device code
153 // cannot be traded for a second token.
154 func (s *Service) ConsumeDeviceCode(ctx context.Context, deviceCode string) {
155 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
156 defer cancel()
157
158 _, _ = s.db.ExecContext(cctx, `UPDATE device_codes SET status = 'exchanged' WHERE device_code = ?`, deviceCode)
159 }
160
161 // PollDeviceCode returns the current grant status and marks the poll time.
162 func (s *Service) PollDeviceCode(ctx context.Context, deviceCode string) (*DeviceCode, error) {
163 deviceCode = strings.TrimSpace(deviceCode)
164 if deviceCode == "" {
165 return nil, ErrDeviceCodeNotFound
166 }
167
168 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
169 defer cancel()
170
171 var dc DeviceCode
172 var userID sql.NullInt64
173 var expiresAt time.Time
174 err := s.db.QueryRowContext(cctx, `
175 SELECT device_code, user_code, client_id, user_id, status, expires_at
176 FROM device_codes WHERE device_code = ?
177 `, deviceCode).Scan(&dc.Code, &dc.UserCode, &dc.ClientID, &userID, &dc.Status, &expiresAt)
178 if err != nil {
179 if errors.Is(err, sql.ErrNoRows) {
180 return nil, ErrDeviceCodeNotFound
181 }
182 return nil, fmt.Errorf("poll device code: %w", err)
183 }
184 if userID.Valid {
185 dc.UserID = &userID.Int64
186 }
187 dc.ExpiresAt = expiresAt
188
189 _, _ = s.db.ExecContext(cctx, `UPDATE device_codes SET last_polled_at = ? WHERE device_code = ?`, time.Now().UTC(), deviceCode)
190
191 if time.Now().UTC().After(expiresAt) && dc.Status == DeviceStatusPending {
192 dc.Status = "expired"
193 }
194 return &dc, nil
195 }
196