package auth import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/haopengzhan/rsspipe/internal/config" "github.com/haopengzhan/rsspipe/internal/db" "golang.org/x/crypto/bcrypt" ) type contextKey string const UserContextKey contextKey = "user" type Claims struct { UserID string `json:"user_id"` Username string `json:"username"` jwt.RegisteredClaims } type AuthService struct { db *db.DB config *config.Config } func NewAuthService(database *db.DB, cfg *config.Config) *AuthService { return &AuthService{ db: database, config: cfg, } } func (s *AuthService) BootstrapAdmin() error { count, err := s.db.CountUsers() if err != nil { return fmt.Errorf("failed to count users: %w", err) } if count == 0 { hash, err := bcrypt.GenerateFromPassword([]byte(s.config.AdminPassword), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("failed to hash admin password: %w", err) } _, err = s.db.CreateUser(s.config.AdminUsername, string(hash)) if err != nil { return fmt.Errorf("failed to bootstrap admin user: %w", err) } fmt.Printf("==> Initialized Admin user: '%s'\n", s.config.AdminUsername) } return nil } func (s *AuthService) Authenticate(username, password string) (string, error) { u, err := s.db.GetUserByUsername(username) if err != nil { return "", errors.New("invalid username or password") } if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { return "", errors.New("invalid username or password") } claims := Claims{ UserID: u.ID, Username: u.Username, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: "rsspipe", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString([]byte(s.config.JWTSecret)) if err != nil { return "", fmt.Errorf("failed to sign token: %w", err) } return tokenString, nil } func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(s.config.JWTSecret), nil }) if err != nil { return nil, err } if claims, ok := token.Claims.(*Claims); ok && token.Valid { return claims, nil } return nil, errors.New("invalid token") } func (s *AuthService) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var tokenStr string // 1. Check Authorization header authHeader := r.Header.Get("Authorization") if authHeader != "" { parts := strings.Split(authHeader, " ") if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { tokenStr = parts[1] } } // 2. Check Cookie if tokenStr == "" { if cookie, err := r.Cookie("rsspipe_token"); err == nil { tokenStr = cookie.Value } } if tokenStr == "" { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } claims, err := s.ValidateToken(tokenStr) if err != nil { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } ctx := context.WithValue(r.Context(), UserContextKey, claims) next.ServeHTTP(w, r.WithContext(ctx)) }) }