add users logic
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/domain"
|
||||
)
|
||||
|
||||
// registerRequest / loginRequest are the JSON bodies clients POST. Kept separate
|
||||
// from the domain so the wire format can evolve without touching the entity.
|
||||
type registerRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// userResponse is the public view of a user. It never carries the password hash.
|
||||
type userResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User userResponse `json:"user"`
|
||||
}
|
||||
|
||||
func toUserResponse(u *domain.User) userResponse {
|
||||
return userResponse{
|
||||
ID: u.ID.String(),
|
||||
Email: u.Email,
|
||||
Role: string(u.Role),
|
||||
CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/users/internal/usecase"
|
||||
)
|
||||
|
||||
// maxJSONBody caps a request body. Credentials are tiny; anything larger is a
|
||||
// mistake or an attack, so reject it before allocating.
|
||||
const maxJSONBody = 1 << 20 // 1 MiB
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// writeError maps a domain or use-case error to an HTTP status and a safe
|
||||
// message, logging only genuine server faults (5xx). Client errors (4xx) are
|
||||
// expected and stay out of the error log.
|
||||
func writeError(w http.ResponseWriter, r *http.Request, log *slog.Logger, err error) {
|
||||
status, msg := statusForError(err)
|
||||
if status >= http.StatusInternalServerError {
|
||||
log.Error("request failed",
|
||||
"err", err,
|
||||
"request_id", requestIDFrom(r.Context()),
|
||||
"path", r.URL.Path,
|
||||
)
|
||||
}
|
||||
writeJSON(w, status, errorResponse{Error: msg, RequestID: requestIDFrom(r.Context())})
|
||||
}
|
||||
|
||||
func statusForError(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, usecase.ErrEmailExists):
|
||||
return http.StatusConflict, "email already registered"
|
||||
case errors.Is(err, usecase.ErrInvalidCredentials):
|
||||
return http.StatusUnauthorized, "invalid email or password"
|
||||
case errors.Is(err, usecase.ErrUserNotFound):
|
||||
return http.StatusNotFound, "user not found"
|
||||
case errors.Is(err, usecase.ErrPasswordTooShort):
|
||||
return http.StatusBadRequest, "password must be at least 8 characters"
|
||||
case errors.Is(err, usecase.ErrPasswordTooLong):
|
||||
return http.StatusBadRequest, "password must be at most 72 bytes"
|
||||
case errors.Is(err, domain.ErrEmptyEmail), errors.Is(err, domain.ErrInvalidEmail):
|
||||
return http.StatusBadRequest, "email is not a valid address"
|
||||
default:
|
||||
// Don't leak internals; the real error is in the log under request_id.
|
||||
return http.StatusInternalServerError, "internal error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/usecase"
|
||||
)
|
||||
|
||||
// Handlers holds the use cases each endpoint drives.
|
||||
type Handlers struct {
|
||||
register *usecase.Register
|
||||
login *usecase.Login
|
||||
users usecase.UserRepository
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// handleHealth is an unauthenticated liveness probe for the container and load
|
||||
// balancer.
|
||||
func (h *Handlers) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleRegister creates an account. It returns 201 with the public user view,
|
||||
// 409 if the email is taken, or 400 on a malformed body / weak password.
|
||||
func (h *Handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
u, err := h.register.Execute(r.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toUserResponse(u))
|
||||
}
|
||||
|
||||
// handleLogin verifies credentials and returns a signed token plus the user.
|
||||
func (h *Handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
token, u, err := h.login.Execute(r.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, loginResponse{Token: token, User: toUserResponse(u)})
|
||||
}
|
||||
|
||||
// handleMe returns the caller's own account, proving the token works end to end.
|
||||
// It reads the user id the JWT middleware stashed in the context.
|
||||
func (h *Handlers) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := userIDFrom(r.Context())
|
||||
if !ok {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
u, err := h.users.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toUserResponse(u))
|
||||
}
|
||||
|
||||
// decodeJSON reads a size-capped JSON body into dst, rejecting unknown fields.
|
||||
// It writes a 400 and returns false on any problem, so callers can `if
|
||||
// !decodeJSON(...) { return }`.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "invalid JSON body",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/auth"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
requestIDKey ctxKey = "request_id"
|
||||
userIDKey ctxKey = "user_id"
|
||||
roleKey ctxKey = "role"
|
||||
)
|
||||
|
||||
// withRequestID stamps every request with an ID for correlated logs and error
|
||||
// bodies. It wraps the auth middleware rather than the other way round, so even
|
||||
// a rejected request carries an ID the caller can quote in a bug report.
|
||||
func withRequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := newRequestID()
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, id)))
|
||||
})
|
||||
}
|
||||
|
||||
func requestIDFrom(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(requestIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// tokenVerifier is the slice of auth.Issuer the JWT middleware needs. Taking an
|
||||
// interface keeps the middleware testable with a stub verifier.
|
||||
type tokenVerifier interface {
|
||||
Verify(token string) (*auth.Claims, error)
|
||||
}
|
||||
|
||||
// withJWT verifies the Bearer token and stashes the caller's id and role in the
|
||||
// request context. It rejects any request without a valid, unexpired HS256
|
||||
// token — this is what protects endpoints that act on a specific user.
|
||||
func withJWT(v tokenVerifier) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if raw == "" {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
claims, err := v.Verify(raw)
|
||||
if err != nil {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(claims.Subject)
|
||||
if err != nil {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userIDKey, id)
|
||||
ctx = context.WithValue(ctx, roleKey, claims.Role)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "unauthorized",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
}
|
||||
|
||||
// userIDFrom returns the authenticated caller's id, set by withJWT.
|
||||
func userIDFrom(ctx context.Context) (uuid.UUID, bool) {
|
||||
id, ok := ctx.Value(userIDKey).(uuid.UUID)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// statusRecorder captures the status code for the access log.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// withAccessLog records one structured line per request — the minimum needed to
|
||||
// debug a distributed system after the fact.
|
||||
func withAccessLog(log *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
log.Info("request",
|
||||
"request_id", requestIDFrom(r.Context()),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// chain applies middleware so that the first argument is the outermost layer.
|
||||
func chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
|
||||
for i := len(mw) - 1; i >= 0; i-- {
|
||||
h = mw[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Package http exposes the userservice over HTTP: registration, login, and a
|
||||
// token-protected /me. It owns routing, request decoding, and error mapping;
|
||||
// business rules live in the usecase layer.
|
||||
package http
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/auth"
|
||||
"github.com/emil28092005/SciMesh/users/internal/usecase"
|
||||
)
|
||||
|
||||
// UseCases bundles the application services the handlers drive.
|
||||
type UseCases struct {
|
||||
Register *usecase.Register
|
||||
Login *usecase.Login
|
||||
Users usecase.UserRepository
|
||||
}
|
||||
|
||||
// NewServer wires the routes and the middleware stack and returns the handler.
|
||||
// The issuer verifies tokens for the protected /me route.
|
||||
func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
|
||||
h := &Handlers{
|
||||
register: uc.Register,
|
||||
login: uc.Login,
|
||||
users: uc.Users,
|
||||
log: log,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
// Method-aware patterns (Go 1.22+): a GET to /register is a 405, not a match.
|
||||
mux.HandleFunc("GET /health", h.handleHealth)
|
||||
mux.HandleFunc("POST /register", h.handleRegister)
|
||||
mux.HandleFunc("POST /login", h.handleLogin)
|
||||
// /me proves a token round-trips; it sits behind JWT auth.
|
||||
mux.Handle("GET /me", chain(http.HandlerFunc(h.handleMe), withJWT(issuer)))
|
||||
|
||||
// Outermost first: every request gets an ID and an access-log line.
|
||||
return chain(mux, withRequestID, withAccessLog(log))
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package http_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/auth"
|
||||
"github.com/emil28092005/SciMesh/users/internal/memstore"
|
||||
apihttp "github.com/emil28092005/SciMesh/users/internal/transport/http"
|
||||
"github.com/emil28092005/SciMesh/users/internal/usecase"
|
||||
)
|
||||
|
||||
const secret = "server-test-secret-32-bytes-long!!!!"
|
||||
|
||||
func newTestServer() http.Handler {
|
||||
users := memstore.NewUserRepo()
|
||||
hasher := auth.NewHasher(4)
|
||||
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
||||
// Real clock for the issuer so tokens are valid at verification time.
|
||||
issuer := auth.NewIssuer(secret, time.Hour, nil)
|
||||
|
||||
uc := apihttp.UseCases{
|
||||
Register: usecase.NewRegister(users, hasher, clk),
|
||||
Login: usecase.NewLogin(users, hasher, issuer),
|
||||
Users: users,
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return apihttp.NewServer(log, uc, issuer)
|
||||
}
|
||||
|
||||
func do(t *testing.T, h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestRegisterThenLoginThenMe(t *testing.T) {
|
||||
h := newTestServer()
|
||||
creds := map[string]string{"email": "flow@example.com", "password": "password123"}
|
||||
|
||||
// Register -> 201
|
||||
rec := do(t, h, http.MethodPost, "/register", "", creds)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("register: got %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// Login -> 200 with a token
|
||||
rec = do(t, h, http.MethodPost, "/login", "", creds)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login: got %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var lr struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lr.Token == "" || lr.User.Email != "flow@example.com" || lr.User.Role != "user" {
|
||||
t.Fatalf("unexpected login body: %+v", lr)
|
||||
}
|
||||
|
||||
// /me with the token -> 200, same user
|
||||
rec = do(t, h, http.MethodGet, "/me", lr.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("me: got %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var me struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if me.Email != "flow@example.com" {
|
||||
t.Errorf("me email = %q", me.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDuplicate(t *testing.T) {
|
||||
h := newTestServer()
|
||||
creds := map[string]string{"email": "dup@example.com", "password": "password123"}
|
||||
_ = do(t, h, http.MethodPost, "/register", "", creds)
|
||||
|
||||
rec := do(t, h, http.MethodPost, "/register", "", creds)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("duplicate register: got %d, want 409", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterValidation(t *testing.T) {
|
||||
h := newTestServer()
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]string
|
||||
}{
|
||||
{"weak password", map[string]string{"email": "a@b.com", "password": "short"}},
|
||||
{"bad email", map[string]string{"email": "nope", "password": "password123"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := do(t, h, http.MethodPost, "/register", "", tc.body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsUnknownFields(t *testing.T) {
|
||||
h := newTestServer()
|
||||
rec := do(t, h, http.MethodPost, "/register", "", map[string]string{
|
||||
"email": "a@b.com", "password": "password123", "role": "admin",
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("unknown field must be rejected: got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
h := newTestServer()
|
||||
_ = do(t, h, http.MethodPost, "/register", "", map[string]string{
|
||||
"email": "x@example.com", "password": "password123",
|
||||
})
|
||||
rec := do(t, h, http.MethodPost, "/login", "", map[string]string{
|
||||
"email": "x@example.com", "password": "wrongpass1",
|
||||
})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeRequiresToken(t *testing.T) {
|
||||
h := newTestServer()
|
||||
if rec := do(t, h, http.MethodGet, "/me", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("no token: got %d, want 401", rec.Code)
|
||||
}
|
||||
if rec := do(t, h, http.MethodGet, "/me", "garbage.token.here", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("bad token: got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
h := newTestServer()
|
||||
if rec := do(t, h, http.MethodGet, "/health", "", nil); rec.Code != http.StatusOK {
|
||||
t.Errorf("health: got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user