Files
carvalho-finances/apps/api/internal/middleware/auth.go
T
Mlcavalho1andClaude Sonnet 4.6 acbce4edc0 feat: multi-usuário com autenticação JWT
- 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]>
2026-05-27 20:04:44 -03:00

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)
}