Files
go-simple-api/lessons/lesson-05-password-login-bcrypt.md
T
2026-07-16 10:13:46 +03:30

13 KiB

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.

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

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:

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:

go get golang.org/x/crypto/bcrypt@latest

internal/handlers/auth.go — the register and login handlers:

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:

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 anyany (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:

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:

	userRepo := models.NewUserRepository(db)
	_ = userRepo

	r := router.New(logger)

with:

	r := router.New(logger, db)

(Delete the userRepo lines from main.go entirely — that construction now happens inside router.New.)

Try it

go run ./cmd/api

Register:

curl -X POST http://localhost:8080/register \
  -H "Content-Type: application/json" \
  -d '{"email":"hamid@example.com","password":"secret123"}'

Login:

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.