411 lines
13 KiB
Markdown
411 lines
13 KiB
Markdown
# 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`.
|