package middleware import ( "context" "encoding/json" "errors" "flag" "fmt" "net/http" "os" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "financeiro-carvalho/internal/model" ) type contextKey int const ( profileIDKey contextKey = 0 profileNameKey contextKey = 1 ) const tokenDuration = 24 * time.Hour func jwtSecret() []byte { s := os.Getenv("JWT_SECRET") if s == "" { s = "change-me-in-production" } return []byte(s) } func jsonErr(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } // ProfileIDFromCtx extracts the authenticated profile ID from the request context. func ProfileIDFromCtx(ctx context.Context) int { v, _ := ctx.Value(profileIDKey).(int) return v } // ProfileNameFromCtx extracts the authenticated profile name from the request context. func ProfileNameFromCtx(ctx context.Context) string { v, _ := ctx.Value(profileNameKey).(string) return v } type claims struct { ProfileID int `json:"pid"` ProfileName string `json:"pname"` jwt.RegisteredClaims } func issueToken(profileID int, name string) (string, error) { c := claims{ ProfileID: profileID, ProfileName: name, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenDuration)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(jwtSecret()) } func parseToken(tokenStr string) (*claims, error) { tok, err := jwt.ParseWithClaims(tokenStr, &claims{}, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.New("unexpected signing method") } return jwtSecret(), nil }) if err != nil || !tok.Valid { return nil, errors.New("invalid token") } c, ok := tok.Claims.(*claims) if !ok { return nil, errors.New("invalid claims") } return c, nil } // RequireAuth validates the Bearer JWT and injects profile_id + profile_name into context. func RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") { jsonErr(w, http.StatusUnauthorized, "unauthorized") return } c, err := parseToken(strings.TrimPrefix(auth, "Bearer ")) if err != nil { jsonErr(w, http.StatusUnauthorized, "unauthorized") return } ctx := context.WithValue(r.Context(), profileIDKey, c.ProfileID) ctx = context.WithValue(ctx, profileNameKey, c.ProfileName) next.ServeHTTP(w, r.WithContext(ctx)) }) } // LoginHandler handles POST /api/auth/login func LoginHandler(pool *pgxpool.Pool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var body struct { Name string `json:"name"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonErr(w, http.StatusBadRequest, "corpo inválido") return } var p model.Profile err := pool.QueryRow(r.Context(), `SELECT id, name, password_hash FROM profiles WHERE name = $1`, body.Name). Scan(&p.ID, &p.Name, &p.PasswordHash) if err != nil || p.PasswordHash == "" { jsonErr(w, http.StatusUnauthorized, "credenciais inválidas") return } if bcrypt.CompareHashAndPassword([]byte(p.PasswordHash), []byte(body.Password)) != nil { jsonErr(w, http.StatusUnauthorized, "credenciais inválidas") return } token, err := issueToken(p.ID, p.Name) if err != nil { jsonErr(w, http.StatusInternalServerError, "erro interno") return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "token": token, "profile": map[string]any{"id": p.ID, "name": p.Name}, }) } } // MeHandler handles GET /api/auth/me (requires auth middleware) func MeHandler(w http.ResponseWriter, r *http.Request) { id := ProfileIDFromCtx(r.Context()) name := ProfileNameFromCtx(r.Context()) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"id": id, "name": name}) } // ChangePasswordHandler handles POST /api/auth/change-password (requires auth middleware) func ChangePasswordHandler(pool *pgxpool.Pool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { profileID := ProfileIDFromCtx(r.Context()) var body struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonErr(w, http.StatusBadRequest, "corpo inválido") return } if len(body.NewPassword) < 8 { jsonErr(w, http.StatusBadRequest, "nova senha deve ter pelo menos 8 caracteres") return } var hash string if err := pool.QueryRow(r.Context(), `SELECT password_hash FROM profiles WHERE id = $1`, profileID).Scan(&hash); err != nil { jsonErr(w, http.StatusInternalServerError, "erro interno") return } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.CurrentPassword)) != nil { jsonErr(w, http.StatusBadRequest, "senha atual incorreta") return } newHash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), 12) if err != nil { jsonErr(w, http.StatusInternalServerError, "erro interno") return } if _, err := pool.Exec(r.Context(), `UPDATE profiles SET password_hash = $1, updated_at = NOW() WHERE id = $2`, string(newHash), profileID); err != nil { jsonErr(w, http.StatusInternalServerError, "erro interno") return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"ok": "true"}) } } // LogoutHandler — JWT is stateless; client drops the token. func LogoutHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } // CreateUser creates or updates a profile with hashed password and seeds data for new profiles. // Used by the CLI subcommand. func CreateUser(ctx context.Context, pool *pgxpool.Pool, name, password string) (*model.Profile, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { return nil, err } var p model.Profile var isNew bool err = pool.QueryRow(ctx, `SELECT id, name FROM profiles WHERE name = $1`, name).Scan(&p.ID, &p.Name) if err != nil { // Profile doesn't exist — create it err = pool.QueryRow(ctx, `INSERT INTO profiles (name, password_hash) VALUES ($1, $2) RETURNING id, name`, name, string(hash)).Scan(&p.ID, &p.Name) if err != nil { return nil, err } isNew = true } else { // Profile exists — update password only if _, err = pool.Exec(ctx, `UPDATE profiles SET password_hash = $1, updated_at = NOW() WHERE id = $2`, string(hash), p.ID); err != nil { return nil, err } } if isNew { if err := seedCategories(ctx, pool, p.ID); err != nil { return nil, err } if _, err := pool.Exec(ctx, `INSERT INTO player_profile (level, xp, xp_to_next, profile_id) VALUES (1, 0, 100, $1)`, p.ID); err != nil { return nil, err } } return &p, nil } func seedCategories(ctx context.Context, pool *pgxpool.Pool, profileID int) error { cats := []struct{ name, color string }{ {"Saúde / Insulina", "#EF4444"}, {"Veleiro", "#3B82F6"}, {"Jogos de Tabuleiro", "#8B5CF6"}, {"Alimentação", "#F59E0B"}, {"Lazer", "#10B981"}, {"Freelance", "#6366F1"}, {"Outros", "#6B7280"}, } for _, c := range cats { if _, err := pool.Exec(ctx, `INSERT INTO categories (name, color, is_default, profile_id) VALUES ($1, $2, true, $3)`, c.name, c.color, profileID); err != nil { return err } } return nil } // RunCreateUser is the CLI entry point for the create-user subcommand. func RunCreateUser(pool *pgxpool.Pool) { fs := flag.NewFlagSet("create-user", flag.ExitOnError) name := fs.String("name", "", "profile name (required)") password := fs.String("password", "", "password (required)") fs.Parse(os.Args[2:]) if *name == "" || *password == "" { fmt.Fprintln(os.Stderr, "usage: ./api create-user --name --password ") os.Exit(1) } if len(*password) < 8 { fmt.Fprintln(os.Stderr, "error: password must be at least 8 characters") os.Exit(1) } ctx := context.Background() p, err := CreateUser(ctx, pool, *name, *password) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } fmt.Printf("ok: profile id=%d name=%s\n", p.ID, p.Name) }