diff --git a/internal/api/api.go b/internal/api/api.go index c3cff8f..9de9798 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -3,6 +3,7 @@ package api import ( "context" "net/http" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -54,12 +55,31 @@ type ProviderRegistry interface { ByName(name string) (provider.Provider, error) } +// AuthStore is the persistence surface the auth handlers depend on. +// *store.Store satisfies it directly (see internal/store/store.go); tests +// can supply their own mock. +type AuthStore interface { + RegisterUser(ctx context.Context, email, passwordHash string) (store.User, store.Project, error) + GetUserByEmail(ctx context.Context, email string) (store.User, error) + GetUserProject(ctx context.Context, userID uuid.UUID) (store.Project, error) +} + +// SessionManager creates/validates/destroys login sessions. *auth.Sessions +// satisfies it directly (see internal/auth/session.go). +type SessionManager interface { + Create(ctx context.Context, userID uuid.UUID) (string, time.Time, error) + Validate(ctx context.Context, token string) (uuid.UUID, error) + Destroy(ctx context.Context, token string) error +} + // API holds handler dependencies. type API struct { - Svc CheckApplier - Store TenantStore - Cipher Cipher - Reg ProviderRegistry + Svc CheckApplier + Store TenantStore + Cipher Cipher + Reg ProviderRegistry + Auth AuthStore + Sessions SessionManager } func NewRouter(a *API) http.Handler { @@ -67,6 +87,13 @@ func NewRouter(a *API) http.Handler { r.Use(middleware.RequestID) r.Use(middleware.Recoverer) + r.Route("/api/v1/auth", func(r chi.Router) { + r.Post("/register", a.handleRegister) + r.Post("/login", a.handleLogin) + r.Post("/logout", a.handleLogout) // защитится RequireAuth в Task 4 + r.Get("/me", a.handleMe) // защитится RequireAuth в Task 4 + }) + r.Route("/api/v1/projects/{pid}", func(r chi.Router) { r.Route("/domains", func(r chi.Router) { r.Post("/", a.handleCreateDomain) diff --git a/internal/api/auth_handlers.go b/internal/api/auth_handlers.go new file mode 100644 index 0000000..5ec3c04 --- /dev/null +++ b/internal/api/auth_handlers.go @@ -0,0 +1,154 @@ +package api + +import ( + "context" + "log" + "net/http" + "time" + + "github.com/google/uuid" + + "github.com/vasyakrg/dns-autoresolver/internal/auth" +) + +const sessionCookieName = "session" + +// ctxKeyUserID is a private context key carrying the authenticated user's ID. +// Task 4's RequireAuth middleware sets it after validating the session +// cookie; handleMe reads it back. +type ctxKeyUserID struct{} + +// userIDFromContext extracts the authenticated user ID set by RequireAuth +// (Task 4). Until that middleware is wired in, tests set it directly via +// context.WithValue. +func userIDFromContext(ctx context.Context) (uuid.UUID, bool) { + id, ok := ctx.Value(ctxKeyUserID{}).(uuid.UUID) + return id, ok +} + +func setSessionCookie(w http.ResponseWriter, token string, exp time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, Value: token, Path: "/", + HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, Expires: exp, + }) +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, Value: "", Path: "/", + HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, + }) +} + +func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if !decodeBody(w, r, &req) { + return + } + if req.Email == "" || req.Password == "" { + writeErr(w, http.StatusBadRequest, "email and password are required") + return + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + log.Printf("api: hash password failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + + u, p, err := a.Auth.RegisterUser(r.Context(), req.Email, hash) + if err != nil { + log.Printf("api: register user failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + + token, exp, err := a.Sessions.Create(r.Context(), u.ID) + if err != nil { + log.Printf("api: create session failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + + setSessionCookie(w, token, exp) + writeJSON(w, http.StatusOK, toAuthResponse(u, p)) +} + +// invalidCredentials is deliberately identical for "no such user" and "wrong +// password" — disclosing which one occurred would let an attacker enumerate +// registered emails. +func invalidCredentials(w http.ResponseWriter) { + writeErr(w, http.StatusUnauthorized, "invalid credentials") +} + +func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if !decodeBody(w, r, &req) { + return + } + + u, err := a.Auth.GetUserByEmail(r.Context(), req.Email) + if err != nil { + invalidCredentials(w) + return + } + + ok, err := auth.VerifyPassword(u.PasswordHash, req.Password) + if err != nil || !ok { + invalidCredentials(w) + return + } + + p, err := a.Auth.GetUserProject(r.Context(), u.ID) + if err != nil { + log.Printf("api: get user project failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + + token, exp, err := a.Sessions.Create(r.Context(), u.ID) + if err != nil { + log.Printf("api: create session failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + + setSessionCookie(w, token, exp) + writeJSON(w, http.StatusOK, toAuthResponse(u, p)) +} + +func (a *API) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" { + if err := a.Sessions.Destroy(r.Context(), c.Value); err != nil { + log.Printf("api: destroy session failed: %v", err) + } + } + clearSessionCookie(w) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleMe returns the authenticated caller's identity + default project. +// The user ID comes from the request context, set by Task 4's RequireAuth +// middleware after validating the session cookie (tests set it directly via +// context.WithValue in the interim). AuthStore has no GetUserByID — the +// email field is intentionally left empty here; see task-3-report.md. +func (a *API) handleMe(w http.ResponseWriter, r *http.Request) { + userID, ok := userIDFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "authentication required") + return + } + + p, err := a.Auth.GetUserProject(r.Context(), userID) + if err != nil { + log.Printf("api: get user project failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + + writeJSON(w, http.StatusOK, authResponse{ + User: userResponse{ID: userID.String()}, + Project: projectResponse{ID: p.ID.String(), Name: p.Name}, + }) +} diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go new file mode 100644 index 0000000..13ecbb9 --- /dev/null +++ b/internal/api/auth_test.go @@ -0,0 +1,250 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/vasyakrg/dns-autoresolver/internal/auth" + "github.com/vasyakrg/dns-autoresolver/internal/store" +) + +// --- mocks --- + +type mockAuthStore struct { + registerUserFn func(ctx context.Context, email, passwordHash string) (store.User, store.Project, error) + getUserByEmailFn func(ctx context.Context, email string) (store.User, error) + getUserProjectFn func(ctx context.Context, userID uuid.UUID) (store.Project, error) +} + +func (m *mockAuthStore) RegisterUser(ctx context.Context, email, passwordHash string) (store.User, store.Project, error) { + return m.registerUserFn(ctx, email, passwordHash) +} + +func (m *mockAuthStore) GetUserByEmail(ctx context.Context, email string) (store.User, error) { + return m.getUserByEmailFn(ctx, email) +} + +func (m *mockAuthStore) GetUserProject(ctx context.Context, userID uuid.UUID) (store.Project, error) { + return m.getUserProjectFn(ctx, userID) +} + +type mockSessionManager struct { + createFn func(ctx context.Context, userID uuid.UUID) (string, time.Time, error) + + destroyCalled bool + destroyToken string + destroyErr error +} + +func (m *mockSessionManager) Create(ctx context.Context, userID uuid.UUID) (string, time.Time, error) { + return m.createFn(ctx, userID) +} + +func (m *mockSessionManager) Validate(context.Context, string) (uuid.UUID, error) { + return uuid.Nil, nil +} + +func (m *mockSessionManager) Destroy(ctx context.Context, token string) error { + m.destroyCalled = true + m.destroyToken = token + return m.destroyErr +} + +func newTestAuthAPI() (*API, *mockAuthStore, *mockSessionManager) { + authStore := &mockAuthStore{} + sessions := &mockSessionManager{ + createFn: func(_ context.Context, userID uuid.UUID) (string, time.Time, error) { + return "test-token", time.Now().Add(time.Hour), nil + }, + } + return &API{Auth: authStore, Sessions: sessions}, authStore, sessions +} + +func findCookie(resp *http.Response, name string) *http.Cookie { + for _, c := range resp.Cookies() { + if c.Name == name { + return c + } + } + return nil +} + +// --- register --- + +func TestAuthRegister_Success(t *testing.T) { + a, authStore, _ := newTestAuthAPI() + userID := uuid.New() + projectID := uuid.New() + authStore.registerUserFn = func(_ context.Context, email, passwordHash string) (store.User, store.Project, error) { + if passwordHash == "" { + t.Fatal("expected non-empty password hash passed to RegisterUser") + } + return store.User{ID: userID, Email: email, PasswordHash: passwordHash}, + store.Project{ID: projectID, UserID: userID, Name: "default"}, nil + } + + router := NewRouter(a) + body := `{"email":"alice@example.com","password":"correct-horse"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d, body %s", w.Code, w.Body.String()) + } + + resp := w.Result() + cookie := findCookie(resp, sessionCookieName) + if cookie == nil { + t.Fatal("expected session cookie to be set") + } + if cookie.Value != "test-token" { + t.Fatalf("unexpected cookie value: %q", cookie.Value) + } + + if strings.Contains(w.Body.String(), "password") { + t.Fatalf("response body must not contain password/password_hash: %s", w.Body.String()) + } + + var got authResponse + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.User.ID != userID.String() || got.User.Email != "alice@example.com" { + t.Fatalf("unexpected user in response: %+v", got.User) + } + if got.Project.ID != projectID.String() || got.Project.Name != "default" { + t.Fatalf("unexpected project in response: %+v", got.Project) + } +} + +// --- login --- + +func TestAuthLogin_CorrectPassword(t *testing.T) { + a, authStore, _ := newTestAuthAPI() + hash, err := auth.HashPassword("correct-horse") + if err != nil { + t.Fatal(err) + } + userID := uuid.New() + projectID := uuid.New() + authStore.getUserByEmailFn = func(_ context.Context, email string) (store.User, error) { + return store.User{ID: userID, Email: email, PasswordHash: hash}, nil + } + authStore.getUserProjectFn = func(_ context.Context, uid uuid.UUID) (store.Project, error) { + return store.Project{ID: projectID, UserID: uid, Name: "default"}, nil + } + + router := NewRouter(a) + body := `{"email":"bob@example.com","password":"correct-horse"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d, body %s", w.Code, w.Body.String()) + } + if findCookie(w.Result(), sessionCookieName) == nil { + t.Fatal("expected session cookie to be set") + } + if strings.Contains(w.Body.String(), "password") { + t.Fatalf("response body must not contain password/password_hash: %s", w.Body.String()) + } +} + +func TestAuthLogin_WrongPassword(t *testing.T) { + a, authStore, _ := newTestAuthAPI() + hash, err := auth.HashPassword("correct-horse") + if err != nil { + t.Fatal(err) + } + authStore.getUserByEmailFn = func(_ context.Context, email string) (store.User, error) { + return store.User{ID: uuid.New(), Email: email, PasswordHash: hash}, nil + } + + router := NewRouter(a) + body := `{"email":"bob@example.com","password":"wrong-password"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assertInvalidCredentials(t, w) +} + +func TestAuthLogin_UnknownEmail(t *testing.T) { + a, authStore, _ := newTestAuthAPI() + authStore.getUserByEmailFn = func(_ context.Context, email string) (store.User, error) { + return store.User{}, errNoRowsForTest + } + + router := NewRouter(a) + body := `{"email":"nobody@example.com","password":"whatever"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assertInvalidCredentials(t, w) +} + +func assertInvalidCredentials(t *testing.T, w *httptest.ResponseRecorder) { + t.Helper() + if w.Code != http.StatusUnauthorized { + t.Fatalf("status %d, body %s", w.Code, w.Body.String()) + } + var got map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["error"] != "invalid credentials" { + t.Fatalf(`expected error "invalid credentials", got %q`, got["error"]) + } + if findCookie(w.Result(), sessionCookieName) != nil { + t.Fatal("expected no session cookie on failed login") + } +} + +// --- logout --- + +func TestAuthLogout_ClearsSessionAndDestroys(t *testing.T) { + a, _, sessions := newTestAuthAPI() + + router := NewRouter(a) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "some-token"}) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d, body %s", w.Code, w.Body.String()) + } + if !sessions.destroyCalled { + t.Fatal("expected Sessions.Destroy to be called") + } + if sessions.destroyToken != "some-token" { + t.Fatalf("expected Destroy called with cookie token, got %q", sessions.destroyToken) + } + + cookie := findCookie(w.Result(), sessionCookieName) + if cookie == nil { + t.Fatal("expected a session cookie in the response (clearing cookie)") + } + if cookie.MaxAge > 0 { + t.Fatalf("expected cookie MaxAge <= 0 (cleared), got %d", cookie.MaxAge) + } +} + +// errNoRowsForTest stands in for a "not found" error a real store would +// return (e.g. pgx.ErrNoRows) — handlers must not distinguish it from any +// other GetUserByEmail failure in the response they send. +var errNoRowsForTest = ¬FoundErr{} + +type notFoundErr struct{} + +func (*notFoundErr) Error() string { return "not found" } diff --git a/internal/api/dto.go b/internal/api/dto.go index fdc5a13..b127976 100644 --- a/internal/api/dto.go +++ b/internal/api/dto.go @@ -1,6 +1,43 @@ package api -import "github.com/vasyakrg/dns-autoresolver/internal/diff" +import ( + "github.com/vasyakrg/dns-autoresolver/internal/diff" + "github.com/vasyakrg/dns-autoresolver/internal/store" +) + +type registerRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// userResponse and projectResponse deliberately expose only id/email and +// id/name — password_hash must never reach a client response. +type userResponse struct { + ID string `json:"id"` + Email string `json:"email"` +} + +type projectResponse struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type authResponse struct { + User userResponse `json:"user"` + Project projectResponse `json:"project"` +} + +func toAuthResponse(u store.User, p store.Project) authResponse { + return authResponse{ + User: userResponse{ID: u.ID.String(), Email: u.Email}, + Project: projectResponse{ID: p.ID.String(), Name: p.Name}, + } +} type applyRequest struct { ApplyUpdates bool `json:"applyUpdates"`