rin
Public Open-source lightweight git hosting.
auth.go · 280 lines · 7.4 KB
Raw
1 package auth
2
3 import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "crypto/subtle"
8 "database/sql"
9 "encoding/base64"
10 "encoding/hex"
11 "errors"
12 "fmt"
13 "strings"
14 "time"
15
16 "polar/internal/db"
17
18 "golang.org/x/crypto/argon2"
19 )
20
21 var (
22 ErrInvalidCredentials = errors.New("invalid credentials")
23 ErrSessionExpired = errors.New("session expired or not found")
24 ErrInvalidToken = errors.New("invalid access token")
25 )
26
27 const (
28 argonTime uint32 = 1
29 argonMemory uint32 = 64 * 1024
30 argonThreads uint8 = 4
31 argonKeyLen uint32 = 32
32 saltLen = 16
33 SessionTTL = 30 * 24 * time.Hour
34 )
35
36 type Session struct {
37 ID string
38 UserID int64
39 CSRFToken string
40 ExpiresAt time.Time
41 CreatedAt time.Time
42 }
43
44 type AccessToken struct {
45 ID int64
46 UserID int64
47 Name string
48 TokenPrefix string
49 LastUsedAt *time.Time
50 CreatedAt time.Time
51 }
52
53 type Service struct {
54 db *db.DB
55 }
56
57 func NewService(database *db.DB) *Service {
58 return &Service{db: database}
59 }
60
61 func HashPassword(password string) (string, error) {
62 if len(password) < 8 {
63 return "", errors.New("password must be at least 8 characters")
64 }
65 salt := make([]byte, saltLen)
66 if _, err := rand.Read(salt); err != nil {
67 return "", fmt.Errorf("generate salt: %w", err)
68 }
69 hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
70 b64Salt := base64.RawStdEncoding.EncodeToString(salt)
71 b64Hash := base64.RawStdEncoding.EncodeToString(hash)
72 encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
73 argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash)
74 return encoded, nil
75 }
76
77 func VerifyPassword(password, encodedHash string) (bool, error) {
78 parts := strings.Split(encodedHash, "$")
79 if len(parts) != 6 || parts[1] != "argon2id" {
80 return false, errors.New("invalid argon2id hash format")
81 }
82 var memory, timeCost uint32
83 var threads uint8
84 _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads)
85 if err != nil {
86 return false, fmt.Errorf("parse argon2 params: %w", err)
87 }
88 salt, err := base64.RawStdEncoding.DecodeString(parts[4])
89 if err != nil {
90 return false, fmt.Errorf("decode salt: %w", err)
91 }
92 expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
93 if err != nil {
94 return false, fmt.Errorf("decode hash: %w", err)
95 }
96 computed := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(expectedHash)))
97 return subtle.ConstantTimeCompare(computed, expectedHash) == 1, nil
98 }
99
100 func randomHex(bytesLen int) (string, error) {
101 b := make([]byte, bytesLen)
102 if _, err := rand.Read(b); err != nil {
103 return "", err
104 }
105 return hex.EncodeToString(b), nil
106 }
107
108 func (s *Service) CreateSession(ctx context.Context, userID int64) (*Session, error) {
109 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
110 defer cancel()
111
112 id, err := randomHex(32)
113 if err != nil {
114 return nil, fmt.Errorf("generate session id: %w", err)
115 }
116 csrf, err := randomHex(24)
117 if err != nil {
118 return nil, fmt.Errorf("generate csrf token: %w", err)
119 }
120 now := time.Now().UTC()
121 expires := now.Add(SessionTTL)
122
123 _, err = s.db.ExecContext(cctx, `
124 INSERT INTO sessions (id, user_id, csrf_token, expires_at, created_at)
125 VALUES (?, ?, ?, ?, ?)
126 `, id, userID, csrf, expires, now)
127 if err != nil {
128 return nil, fmt.Errorf("insert session: %w", err)
129 }
130
131 return &Session{
132 ID: id,
133 UserID: userID,
134 CSRFToken: csrf,
135 ExpiresAt: expires,
136 CreatedAt: now,
137 }, nil
138 }
139
140 func (s *Service) GetSession(ctx context.Context, sessionID string) (*Session, error) {
141 if sessionID == "" {
142 return nil, ErrSessionExpired
143 }
144 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
145 defer cancel()
146
147 var sess Session
148 err := s.db.QueryRowContext(cctx, `
149 SELECT id, user_id, csrf_token, expires_at, created_at
150 FROM sessions WHERE id = ?
151 `, sessionID).Scan(&sess.ID, &sess.UserID, &sess.CSRFToken, &sess.ExpiresAt, &sess.CreatedAt)
152 if err != nil {
153 if errors.Is(err, sql.ErrNoRows) {
154 return nil, ErrSessionExpired
155 }
156 return nil, fmt.Errorf("query session: %w", err)
157 }
158
159 if time.Now().UTC().After(sess.ExpiresAt) {
160 _ = s.DeleteSession(ctx, sessionID)
161 return nil, ErrSessionExpired
162 }
163 return &sess, nil
164 }
165
166 func (s *Service) DeleteSession(ctx context.Context, sessionID string) error {
167 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
168 defer cancel()
169 _, err := s.db.ExecContext(cctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
170 return err
171 }
172
173 func HashToken(rawToken string) string {
174 sum := sha256.Sum256([]byte(rawToken))
175 return hex.EncodeToString(sum[:])
176 }
177
178 func (s *Service) CreateToken(ctx context.Context, userID int64, name string) (string, *AccessToken, error) {
179 name = strings.TrimSpace(name)
180 if name == "" || len(name) > 64 {
181 return "", nil, errors.New("token name must be between 1 and 64 characters")
182 }
183
184 secret, err := randomHex(20)
185 if err != nil {
186 return "", nil, fmt.Errorf("generate token secret: %w", err)
187 }
188 rawToken := "polar_" + secret
189 tokenHash := HashToken(rawToken)
190 prefix := rawToken[:10]
191 now := time.Now().UTC()
192
193 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
194 defer cancel()
195
196 res, err := s.db.ExecContext(cctx, `
197 INSERT INTO access_tokens (user_id, name, token_prefix, token_hash, created_at)
198 VALUES (?, ?, ?, ?, ?)
199 `, userID, name, prefix, tokenHash, now)
200 if err != nil {
201 return "", nil, fmt.Errorf("insert access token: %w", err)
202 }
203 id, _ := res.LastInsertId()
204
205 return rawToken, &AccessToken{
206 ID: id,
207 UserID: userID,
208 Name: name,
209 TokenPrefix: prefix,
210 CreatedAt: now,
211 }, nil
212 }
213
214 func (s *Service) VerifyToken(ctx context.Context, rawToken string) (int64, error) {
215 rawToken = strings.TrimSpace(rawToken)
216 if !strings.HasPrefix(rawToken, "polar_") {
217 return 0, ErrInvalidToken
218 }
219 tokenHash := HashToken(rawToken)
220
221 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
222 defer cancel()
223
224 var tokenID, userID int64
225 err := s.db.QueryRowContext(cctx, `
226 SELECT id, user_id FROM access_tokens WHERE token_hash = ?
227 `, tokenHash).Scan(&tokenID, &userID)
228 if err != nil {
229 if errors.Is(err, sql.ErrNoRows) {
230 return 0, ErrInvalidToken
231 }
232 return 0, fmt.Errorf("lookup token: %w", err)
233 }
234
235 now := time.Now().UTC()
236 _, _ = s.db.ExecContext(cctx, `UPDATE access_tokens SET last_used_at = ? WHERE id = ?`, now, tokenID)
237 return userID, nil
238 }
239
240 func (s *Service) ListTokens(ctx context.Context, userID int64) ([]AccessToken, error) {
241 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
242 defer cancel()
243
244 rows, err := s.db.QueryContext(cctx, `
245 SELECT id, user_id, name, token_prefix, last_used_at, created_at
246 FROM access_tokens
247 WHERE user_id = ?
248 ORDER BY created_at DESC
249 `, userID)
250 if err != nil {
251 return nil, fmt.Errorf("list access tokens: %w", err)
252 }
253 defer rows.Close()
254
255 var list []AccessToken
256 for rows.Next() {
257 var t AccessToken
258 var lastUsed sql.NullTime
259 if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &lastUsed, &t.CreatedAt); err != nil {
260 return nil, fmt.Errorf("scan access token: %w", err)
261 }
262 if lastUsed.Valid {
263 tm := lastUsed.Time
264 t.LastUsedAt = &tm
265 }
266 list = append(list, t)
267 }
268 return list, rows.Err()
269 }
270
271 func (s *Service) RevokeToken(ctx context.Context, userID, tokenID int64) error {
272 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
273 defer cancel()
274
275 _, err := s.db.ExecContext(cctx, `DELETE FROM access_tokens WHERE id = ? AND user_id = ?`, tokenID, userID)
276 if err != nil {
277 return fmt.Errorf("revoke token: %w", err)
278 }
279 return nil
280 }
281