first commit
This commit is contained in:
@@ -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`.
|
||||
Reference in New Issue
Block a user