first commit

This commit is contained in:
2026-07-16 10:13:46 +03:30
commit 423c528b2f
42 changed files with 7298 additions and 0 deletions
+410
View File
@@ -0,0 +1,410 @@
# 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`.