13 KiB
Lesson 5 — Password Login with bcrypt
New Go concepts in this lesson: working with
[]bytevsstring,httptestfor testing handlers without a real server, struct tags for JSON (a deeper look). Review the "slices" and "JSON basics" sections of00-go-basics-3-...mdif[]byteconversions 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), notstring. 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
hashandhash2differ — bcrypt generates a random salt internally every time you callGenerateFromPassword, 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 inhash, then compares. Returnsnilon 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)— sameEncoder/Decoderpattern from Lesson 1/Go Basics Part 3, reversed.r.Bodyis anio.ReadCloser(a stream) containing the raw request bytes;Decodeparses JSON straight from it intoreq. The&reqmatters —Decodeneeds to write intoreq, so it needsreq's address.`json:"email"`— a struct tag (Go Basics Part 2). Maps the JSON keyemailto 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 fakehttp.ResponseWriteryou 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 likehandlers.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 asBookRepository/UserRepositoryin Lesson 4.hgives every method access toh.userRepoandh.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 frommodels.Userdeliberately — the wire format shouldn't be coupled to the database model; a register request should never be able to setPasswordHashorIDdirectly.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 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:
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.