diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 060363e..3953c6d 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -14,6 +14,7 @@ import ( "financeiro-carvalho/internal/db" "financeiro-carvalho/internal/handler" + authmw "financeiro-carvalho/internal/middleware" "financeiro-carvalho/internal/migration" "financeiro-carvalho/internal/repository" "financeiro-carvalho/internal/service" @@ -78,7 +79,12 @@ func main() { r.Get("/health", handler.Health) + r.Post("/api/login", authmw.LoginHandler) + r.Post("/api/logout", authmw.LogoutHandler) + r.Get("/api/auth/me", authmw.MeHandler) + r.Route("/api", func(r chi.Router) { + r.Use(authmw.RequireAuth) r.Get("/categories", categoryHandler.List) r.Post("/categories", categoryHandler.Create) r.Put("/categories/{id}", categoryHandler.Update) diff --git a/apps/api/internal/middleware/auth.go b/apps/api/internal/middleware/auth.go new file mode 100644 index 0000000..f8fb572 --- /dev/null +++ b/apps/api/internal/middleware/auth.go @@ -0,0 +1,109 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "os" + "sync" + "time" +) + +const cookieName = "cf_session" + +var ( + mu sync.RWMutex + sessions = map[string]time.Time{} +) + +func newToken() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +func validSession(token string) bool { + mu.RLock() + defer mu.RUnlock() + exp, ok := sessions[token] + return ok && time.Now().Before(exp) +} + +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 || !validSession(cookie.Value) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + next.ServeHTTP(w, r) + }) +} + +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 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "corpo inválido"}) + return + } + + wantUser := os.Getenv("APP_USERNAME") + wantPass := os.Getenv("APP_PASSWORD") + if wantUser == "" || body.Username != wantUser || body.Password != wantPass { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "credenciais inválidas"}) + return + } + + token := newToken() + mu.Lock() + sessions[token] = time.Now().Add(30 * 24 * time.Hour) + mu.Unlock() + + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + MaxAge: 30 * 24 * 60 * 60, + }) + 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) { + if cookie, err := r.Cookie(cookieName); err == nil { + mu.Lock() + delete(sessions, cookie.Value) + mu.Unlock() + } + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + MaxAge: -1, + }) + w.WriteHeader(http.StatusNoContent) +} + +func MeHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(cookieName) + if err != nil || !validSession(cookie.Value) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 680565f..169087f 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -1,9 +1,16 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' +import { useAuthStore } from '@/stores/auth' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ + { + path: '/login', + name: 'login', + component: () => import('../views/LoginView.vue'), + meta: { public: true }, + }, { path: '/', name: 'home', @@ -42,4 +49,11 @@ const router = createRouter({ ], }) +router.beforeEach(async (to) => { + if (to.meta.public) return true + const auth = useAuthStore() + const ok = await auth.check() + if (!ok) return { name: 'login' } +}) + export default router diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts index ba3e99e..0ebf111 100644 --- a/apps/web/src/services/api.ts +++ b/apps/web/src/services/api.ts @@ -1,3 +1,5 @@ +import router from '@/router' + const BASE = '/api' async function request(method: string, path: string, body?: unknown): Promise { @@ -6,6 +8,10 @@ async function request(method: string, path: string, body?: unknown): Promise headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, }) + if (res.status === 401) { + router.push({ name: 'login' }) + throw new Error('Sessão expirada') + } if (res.status === 204) return undefined as T const data = await res.json() if (!res.ok) throw new Error(data.error ?? 'Erro desconhecido') diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts new file mode 100644 index 0000000..2ab4935 --- /dev/null +++ b/apps/web/src/stores/auth.ts @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useAuthStore = defineStore('auth', () => { + const checked = ref(false) + const authenticated = ref(false) + + async function check(): Promise { + if (checked.value) return authenticated.value + try { + const res = await fetch('/api/auth/me') + authenticated.value = res.status === 204 + } catch { + authenticated.value = false + } + checked.value = true + return authenticated.value + } + + async function login(username: string, password: string): Promise { + const res = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error ?? 'Erro ao fazer login') + } + authenticated.value = true + checked.value = true + } + + async function logout(): Promise { + await fetch('/api/logout', { method: 'POST' }) + authenticated.value = false + checked.value = false + } + + return { checked, authenticated, check, login, logout } +}) diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue new file mode 100644 index 0000000..238d27d --- /dev/null +++ b/apps/web/src/views/LoginView.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/docker-compose.yml b/docker-compose.yml index 5b218af..01c0363 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,14 +17,21 @@ services: app: build: . restart: unless-stopped - ports: - - "${APP_PORT:-8080}:8080" environment: DATABASE_URL: postgres://financeiro:${POSTGRES_PASSWORD:-financeiro}@postgres:5432/financeiro?sslmode=disable PORT: 8080 + APP_USERNAME: ${APP_USERNAME} + APP_PASSWORD: ${APP_PASSWORD} depends_on: postgres: condition: service_healthy + networks: + - default + - dokploy-network volumes: postgres_data: + +networks: + dokploy-network: + external: true