feat(api): auth-хендлеры register/login/logout/me + session cookie
This commit is contained in:
@@ -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
|
||||
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)
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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" }
|
||||
+38
-1
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user