- Tabela `profiles` + coluna `profile_id` em todas as entidades (categories, transactions, recurring_expenses, accounts, player_profile, xp_events, player_quests, player_achievements, player_cosmetics) - Dados existentes migrados para profile_id = 1 (Manoel) - CLI `./api create-user --name <n> --password <p>` cria perfil com seed de categorias e player_profile; faz upsert de senha se já existir - Auth substituída: cookie+APP_PASSWORD → JWT Bearer 24h (HS256) - Middleware RequireAuth injeta profile_id no context de todas as rotas - Todos os repositórios filtram por profile_id do context - Endpoints: POST /api/auth/login, GET /api/auth/me, POST /api/auth/change-password, POST /api/logout - Frontend: auth store usa localStorage (fc_token/fc_profile), api.ts envia Authorization header, LoginView usa campo name - SettingsView reescrita com troca de senha e logout - docker-compose.yml: remove APP_USERNAME/APP_PASSWORD, adiciona JWT_SECRET Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
294 lines
8.4 KiB
Go
294 lines
8.4 KiB
Go
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 <name> --password <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)
|
|
}
|