package auth import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "database/sql" "encoding/base64" "encoding/hex" "errors" "fmt" "strings" "time" "polar/internal/db" "golang.org/x/crypto/argon2" ) var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrSessionExpired = errors.New("session expired or not found") ErrInvalidToken = errors.New("invalid access token") ) const ( argonTime uint32 = 1 argonMemory uint32 = 64 * 1024 argonThreads uint8 = 4 argonKeyLen uint32 = 32 saltLen = 16 SessionTTL = 30 * 24 * time.Hour ) type Session struct { ID string UserID int64 CSRFToken string ExpiresAt time.Time CreatedAt time.Time } type AccessToken struct { ID int64 UserID int64 Name string TokenPrefix string LastUsedAt *time.Time CreatedAt time.Time } type Service struct { db *db.DB } func NewService(database *db.DB) *Service { return &Service{db: database} } func HashPassword(password string) (string, error) { if len(password) < 8 { return "", errors.New("password must be at least 8 characters") } salt := make([]byte, saltLen) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("generate salt: %w", err) } hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) b64Salt := base64.RawStdEncoding.EncodeToString(salt) b64Hash := base64.RawStdEncoding.EncodeToString(hash) encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash) return encoded, nil } func VerifyPassword(password, encodedHash string) (bool, error) { parts := strings.Split(encodedHash, "$") if len(parts) != 6 || parts[1] != "argon2id" { return false, errors.New("invalid argon2id hash format") } var memory, timeCost uint32 var threads uint8 _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads) if err != nil { return false, fmt.Errorf("parse argon2 params: %w", err) } salt, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return false, fmt.Errorf("decode salt: %w", err) } expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5]) if err != nil { return false, fmt.Errorf("decode hash: %w", err) } computed := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(expectedHash))) return subtle.ConstantTimeCompare(computed, expectedHash) == 1, nil } func randomHex(bytesLen int) (string, error) { b := make([]byte, bytesLen) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } func (s *Service) CreateSession(ctx context.Context, userID int64) (*Session, error) { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() id, err := randomHex(32) if err != nil { return nil, fmt.Errorf("generate session id: %w", err) } csrf, err := randomHex(24) if err != nil { return nil, fmt.Errorf("generate csrf token: %w", err) } now := time.Now().UTC() expires := now.Add(SessionTTL) _, err = s.db.ExecContext(cctx, ` INSERT INTO sessions (id, user_id, csrf_token, expires_at, created_at) VALUES (?, ?, ?, ?, ?) `, id, userID, csrf, expires, now) if err != nil { return nil, fmt.Errorf("insert session: %w", err) } return &Session{ ID: id, UserID: userID, CSRFToken: csrf, ExpiresAt: expires, CreatedAt: now, }, nil } func (s *Service) GetSession(ctx context.Context, sessionID string) (*Session, error) { if sessionID == "" { return nil, ErrSessionExpired } cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() var sess Session err := s.db.QueryRowContext(cctx, ` SELECT id, user_id, csrf_token, expires_at, created_at FROM sessions WHERE id = ? `, sessionID).Scan(&sess.ID, &sess.UserID, &sess.CSRFToken, &sess.ExpiresAt, &sess.CreatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrSessionExpired } return nil, fmt.Errorf("query session: %w", err) } if time.Now().UTC().After(sess.ExpiresAt) { _ = s.DeleteSession(ctx, sessionID) return nil, ErrSessionExpired } return &sess, nil } func (s *Service) DeleteSession(ctx context.Context, sessionID string) error { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() _, err := s.db.ExecContext(cctx, `DELETE FROM sessions WHERE id = ?`, sessionID) return err } func HashToken(rawToken string) string { sum := sha256.Sum256([]byte(rawToken)) return hex.EncodeToString(sum[:]) } func (s *Service) CreateToken(ctx context.Context, userID int64, name string) (string, *AccessToken, error) { name = strings.TrimSpace(name) if name == "" || len(name) > 64 { return "", nil, errors.New("token name must be between 1 and 64 characters") } secret, err := randomHex(20) if err != nil { return "", nil, fmt.Errorf("generate token secret: %w", err) } rawToken := "polar_" + secret tokenHash := HashToken(rawToken) prefix := rawToken[:10] now := time.Now().UTC() cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() res, err := s.db.ExecContext(cctx, ` INSERT INTO access_tokens (user_id, name, token_prefix, token_hash, created_at) VALUES (?, ?, ?, ?, ?) `, userID, name, prefix, tokenHash, now) if err != nil { return "", nil, fmt.Errorf("insert access token: %w", err) } id, _ := res.LastInsertId() return rawToken, &AccessToken{ ID: id, UserID: userID, Name: name, TokenPrefix: prefix, CreatedAt: now, }, nil } func (s *Service) VerifyToken(ctx context.Context, rawToken string) (int64, error) { rawToken = strings.TrimSpace(rawToken) if !strings.HasPrefix(rawToken, "polar_") { return 0, ErrInvalidToken } tokenHash := HashToken(rawToken) cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() var tokenID, userID int64 err := s.db.QueryRowContext(cctx, ` SELECT id, user_id FROM access_tokens WHERE token_hash = ? `, tokenHash).Scan(&tokenID, &userID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return 0, ErrInvalidToken } return 0, fmt.Errorf("lookup token: %w", err) } now := time.Now().UTC() _, _ = s.db.ExecContext(cctx, `UPDATE access_tokens SET last_used_at = ? WHERE id = ?`, now, tokenID) return userID, nil } func (s *Service) ListTokens(ctx context.Context, userID int64) ([]AccessToken, error) { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() rows, err := s.db.QueryContext(cctx, ` SELECT id, user_id, name, token_prefix, last_used_at, created_at FROM access_tokens WHERE user_id = ? ORDER BY created_at DESC `, userID) if err != nil { return nil, fmt.Errorf("list access tokens: %w", err) } defer rows.Close() var list []AccessToken for rows.Next() { var t AccessToken var lastUsed sql.NullTime if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &lastUsed, &t.CreatedAt); err != nil { return nil, fmt.Errorf("scan access token: %w", err) } if lastUsed.Valid { tm := lastUsed.Time t.LastUsedAt = &tm } list = append(list, t) } return list, rows.Err() } func (s *Service) RevokeToken(ctx context.Context, userID, tokenID int64) error { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() _, err := s.db.ExecContext(cctx, `DELETE FROM access_tokens WHERE id = ? AND user_id = ?`, tokenID, userID) if err != nil { return fmt.Errorf("revoke token: %w", err) } return nil }