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]>
This commit is contained in:
@@ -1,51 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
)
|
||||
|
||||
const cookieName = "cf_session"
|
||||
const sessionDuration = 30 * 24 * time.Hour
|
||||
type contextKey int
|
||||
|
||||
func secret() []byte {
|
||||
// Use APP_PASSWORD as signing secret so tokens survive container restarts.
|
||||
return []byte(os.Getenv("APP_PASSWORD"))
|
||||
}
|
||||
const (
|
||||
profileIDKey contextKey = 0
|
||||
profileNameKey contextKey = 1
|
||||
)
|
||||
|
||||
func signToken(expiry int64) string {
|
||||
nonce := make([]byte, 8)
|
||||
rand.Read(nonce)
|
||||
payload := fmt.Sprintf("%s.%d", hex.EncodeToString(nonce), expiry)
|
||||
mac := hmac.New(sha256.New, secret())
|
||||
mac.Write([]byte(payload))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
return payload + "." + sig
|
||||
}
|
||||
const tokenDuration = 24 * time.Hour
|
||||
|
||||
func validToken(token string) bool {
|
||||
parts := strings.SplitN(token, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
func jwtSecret() []byte {
|
||||
s := os.Getenv("JWT_SECRET")
|
||||
if s == "" {
|
||||
s = "change-me-in-production"
|
||||
}
|
||||
payload := parts[0] + "." + parts[1]
|
||||
expiry, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || time.Now().Unix() > expiry {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, secret())
|
||||
mac.Write([]byte(payload))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(parts[2]), []byte(expected))
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
func jsonErr(w http.ResponseWriter, status int, msg string) {
|
||||
@@ -54,66 +41,253 @@ func jsonErr(w http.ResponseWriter, status int, msg string) {
|
||||
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) {
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil || !validToken(cookie.Value) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
jsonErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, "corpo inválido")
|
||||
return
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
wantUser := os.Getenv("APP_USERNAME")
|
||||
wantPass := os.Getenv("APP_PASSWORD")
|
||||
if wantUser == "" || body.Username != wantUser || body.Password != wantPass {
|
||||
jsonErr(w, http.StatusUnauthorized, "credenciais inválidas")
|
||||
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},
|
||||
})
|
||||
}
|
||||
|
||||
expiry := time.Now().Add(sessionDuration).Unix()
|
||||
token := signToken(expiry)
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(sessionDuration.Seconds()),
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
|
||||
}
|
||||
|
||||
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// MeHandler handles GET /api/auth/me (requires auth middleware)
|
||||
func MeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil || !validToken(cookie.Value) {
|
||||
jsonErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user