# Lesson 5 — Password Login with bcrypt > **New Go concepts in this lesson:** working with `[]byte` vs `string`, > `httptest` for testing handlers without a real server, struct tags for > JSON (a deeper look). Review the "slices" and "JSON basics" sections of > `00-go-basics-3-...md` if `[]byte` conversions look unfamiliar. ## Part A — standalone playground Two things to practice before touching the real project: **hashing passwords with bcrypt**, and **decoding + validating JSON request bodies**. ```bash mkdir ~/go-playground/bcrypt-demo && cd ~/go-playground/bcrypt-demo go mod init bcrypt-demo go get golang.org/x/crypto/bcrypt@latest ``` **`main.go`** ```go package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" "golang.org/x/crypto/bcrypt" ) func main() { // ---- Part 1: bcrypt hashing ---- password := "my-secret-password" // 1. Hash the password. The second argument is the "cost" - higher = // slower = more resistant to brute-force, but more CPU per login. hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { log.Fatal(err) } fmt.Println("hash:", string(hash)) // looks like: $2a$10$N9qo8uLOickgx2ZMRZoMy... // 2. Hash the SAME password again - notice the output is DIFFERENT // each time. hash2, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) fmt.Println("hash2:", string(hash2)) fmt.Println("hashes equal?", string(hash) == string(hash2)) // false! // 3. But both still verify correctly against the original password. err = bcrypt.CompareHashAndPassword(hash, []byte(password)) fmt.Println("hash matches password:", err == nil) err = bcrypt.CompareHashAndPassword(hash2, []byte(password)) fmt.Println("hash2 matches password:", err == nil) // 4. Wrong password correctly fails. err = bcrypt.CompareHashAndPassword(hash, []byte("wrong-password")) fmt.Println("wrong password matches:", err == nil) // ---- Part 2: decoding JSON request bodies ---- type LoginRequest struct { Email string `json:"email"` Password string `json:"password"` } handler := func(w http.ResponseWriter, r *http.Request) { var req LoginRequest // Decode reads the JSON body straight into our struct. if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON body", http.StatusBadRequest) return } // Basic manual validation - no library needed for something this // simple. if req.Email == "" || req.Password == "" { http.Error(w, "email and password are required", http.StatusBadRequest) return } fmt.Fprintf(w, "got email=%s password=%s\n", req.Email, req.Password) } // httptest lets us fire fake HTTP requests without starting a real // server - great for testing handlers directly. body := bytes.NewBufferString(`{"email":"hamid@example.com","password":"secret123"}`) req := httptest.NewRequest(http.MethodPost, "/login", body) rec := httptest.NewRecorder() handler(rec, req) fmt.Println("status:", rec.Code) fmt.Println("body:", rec.Body.String()) } ``` Run it: ```bash go run . ``` Line by line, what matters: - `[]byte(password)` — bcrypt works on `[]byte` (a slice of raw bytes), not `string`. Go strings are already UTF-8 byte sequences under the hood, so `[]byte(someString)` is a cheap, direct conversion — see Go Basics Part 1's type table. - `bcrypt.GenerateFromPassword(..., bcrypt.DefaultCost)` — `DefaultCost` (currently 10) controls how many rounds of internal hashing happen — intentionally slow, on purpose, to make brute-forcing expensive. Returns `([]byte, error)` — the classic multi-return pattern from Go Basics Part 2. - **Why `hash` and `hash2` differ** — bcrypt generates a random **salt** internally every time you call `GenerateFromPassword`, and bakes that salt into the output string itself (visible as part of the `$2a$10$...` format). This means identical passwords produce different hashes, preventing an attacker from spotting "these two users have the same password" just by comparing hashes in a leaked database. - `bcrypt.CompareHashAndPassword(hash, []byte(password))` — the *only* correct way to check a password. It re-derives the hash using the salt embedded in `hash`, then compares. Returns `nil` on match, an error otherwise. **You cannot "unhash" a bcrypt hash back to the original password** — that's the whole point. - `json.NewDecoder(r.Body).Decode(&req)` — same `Encoder`/`Decoder` pattern from Lesson 1/Go Basics Part 3, reversed. `r.Body` is an `io.ReadCloser` (a stream) containing the raw request bytes; `Decode` parses JSON straight from it into `req`. The `&req` matters — `Decode` needs to *write into* `req`, so it needs `req`'s address. - `` `json:"email"` `` — a struct tag (Go Basics Part 2). Maps the JSON key `email` to this Go field regardless of capitalization. Explicit tags are best practice: they document the wire format, and let you rename Go fields freely without breaking the API's JSON shape. - `httptest.NewRequest` / `httptest.NewRecorder` — lets you call a handler function directly, without binding a real port. `NewRecorder()` gives you a fake `http.ResponseWriter` you can inspect afterward (`rec.Code`, `rec.Body`). Very useful for automated tests later. Try breaking the JSON body (remove a quote) and watch the "invalid JSON body" error trigger. Try sending an empty password and see the validation error path. ## Part B — apply it to the project **Add the dependency:** ```bash go get golang.org/x/crypto/bcrypt@latest ``` **`internal/handlers/auth.go`** — the register and login handlers: ```go package handlers import ( "encoding/json" "errors" "log/slog" "net/http" "golang.org/x/crypto/bcrypt" "git.hamidsoltani.com/hamid/go-simple-api/internal/models" ) // AuthHandler groups auth-related handlers together and holds their // shared dependencies (repository, logger) as struct fields. type AuthHandler struct { userRepo *models.UserRepository logger *slog.Logger } func NewAuthHandler(userRepo *models.UserRepository, logger *slog.Logger) *AuthHandler { return &AuthHandler{userRepo: userRepo, logger: logger} } type registerRequest struct { Email string `json:"email"` Password string `json:"password"` } func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { var req registerRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Email == "" || req.Password == "" { writeError(w, http.StatusBadRequest, "email and password are required") return } if len(req.Password) < 8 { writeError(w, http.StatusBadRequest, "password must be at least 8 characters") return } // Check if the email is already taken. _, err := h.userRepo.FindByEmail(r.Context(), req.Email) if err == nil { writeError(w, http.StatusConflict, "email already registered") return } if !errors.Is(err, models.ErrUserNotFound) { h.logger.Error("find user by email failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { h.logger.Error("hash password failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } user := &models.User{ Email: req.Email, PasswordHash: string(hash), } if err := h.userRepo.Create(r.Context(), user); err != nil { h.logger.Error("create user failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusCreated, map[string]any{ "id": user.ID, "email": user.Email, }) } type loginRequest struct { Email string `json:"email"` Password string `json:"password"` } func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } user, err := h.userRepo.FindByEmail(r.Context(), req.Email) if errors.Is(err, models.ErrUserNotFound) { writeError(w, http.StatusUnauthorized, "invalid email or password") return } if err != nil { h.logger.Error("find user by email failed", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { writeError(w, http.StatusUnauthorized, "invalid email or password") return } // Session creation happens here starting Lesson 6. writeJSON(w, http.StatusOK, map[string]any{ "id": user.ID, "email": user.Email, }) } ``` New patterns worth calling out: - `type AuthHandler struct { userRepo *models.UserRepository; logger *slog.Logger }` — instead of standalone functions like `handlers.Health`, these handlers need dependencies. The idiomatic Go way: put dependencies as **fields on a struct**, and make the handlers **methods** on that struct (`func (h *AuthHandler) Register(...)`) — the same pointer-receiver pattern as `BookRepository`/`UserRepository` in Lesson 4. `h` gives every method access to `h.userRepo` and `h.logger`. - `registerRequest` / `loginRequest` — small unexported structs (Go Basics Part 2: lowercase = private to this file/package), scoped just to what each endpoint expects. Kept separate from `models.User` deliberately — the wire format shouldn't be coupled to the database model; a register request should never be able to set `PasswordHash` or `ID` directly. - `if !errors.Is(err, models.ErrUserNotFound)` — "if the error is something *other than* not-found, that's a real, unexpected problem." We separate the *expected* case (email doesn't exist yet — good, proceed) from *unexpected* failures (database down, etc.), logging only the latter. - **In `Login`**: the *same* generic error message (`"invalid email or password"`) covers both "no such email" and "wrong password." This is deliberate — separate messages would let an attacker enumerate which emails are registered. Always give identical, generic feedback for both failure cases in a login flow. **`internal/handlers/respond.go`** — small shared helpers, used by every handler from now on: ```go package handlers import ( "encoding/json" "net/http" ) func writeJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } ``` `data any` — `any` (Go Basics Part 3) accepts a value of any type, which lets `writeJSON` handle both `map[string]any{...}` and, later, any struct we want to serialize. **Update `internal/router/router.go`** to wire the new routes: ```go package router import ( "database/sql" "log/slog" "time" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "git.hamidsoltani.com/hamid/go-simple-api/internal/handlers" "git.hamidsoltani.com/hamid/go-simple-api/internal/middleware" "git.hamidsoltani.com/hamid/go-simple-api/internal/models" ) func New(logger *slog.Logger, db *sql.DB) *chi.Mux { r := chi.NewRouter() r.Use(chimw.RequestID) r.Use(middleware.RequestLogger(logger)) r.Use(chimw.Recoverer) r.Use(chimw.Timeout(60 * time.Second)) r.Get("/health", handlers.Health) userRepo := models.NewUserRepository(db) authHandler := handlers.NewAuthHandler(userRepo, logger) r.Post("/register", authHandler.Register) r.Post("/login", authHandler.Login) return r } ``` `New` now also takes `db *sql.DB` — it needs it to build `userRepo`. Note `r.Post("/register", authHandler.Register)` passes a **method value**: Go bundles `authHandler.Register` together with the specific `authHandler` instance it belongs to, producing something with exactly the `func(http.ResponseWriter, *http.Request)` shape chi expects — even though `Register` is defined with a receiver (`func (h *AuthHandler) Register(...)`). You don't manually pass `authHandler` as an argument; Go's method-value syntax handles that binding for you. **Update `cmd/api/main.go`** — replace: ```go userRepo := models.NewUserRepository(db) _ = userRepo r := router.New(logger) ``` with: ```go r := router.New(logger, db) ``` (Delete the `userRepo` lines from `main.go` entirely — that construction now happens inside `router.New`.) ## Try it ```bash go run ./cmd/api ``` Register: ```bash curl -X POST http://localhost:8080/register \ -H "Content-Type: application/json" \ -d '{"email":"hamid@example.com","password":"secret123"}' ``` Login: ```bash curl -X POST http://localhost:8080/login \ -H "Content-Type: application/json" \ -d '{"email":"hamid@example.com","password":"secret123"}' ``` Try a wrong password (expect `401` with the generic message) and registering the same email twice (expect `409`). Once both parts work, move to Lesson 6 — server-side sessions with scs + Redis, where a successful login finally starts a real session instead of just returning `200`.