1
Fork 0
blog-go-mongo/auth.go
Leonardo Devai a5937fed8d Review and modernize all 42 projects to the updated standard
Pinned 2026 toolchains (Go 1.26, Rust 1.98/edition 2024, Python 3.14 + uv, Node 24,
Zig 0.16, NixOS 26.05), postgres 18 / mongo 8, lockfiles built from, non-root
runtimes, .dockerignore, per-project LICENSE, READMEs with the git.devai.io clone
line, checkout@v7 CI. Security fixes in the legacy Rust APIs (any-password login,
self-assigned admin, hard-coded JWT secret), JWT alg/exp/sub enforcement across the
blog series, safe markdown links in the frontends, and many smaller bugs — every
project was built, run and exercised end to end.

Adds scripts/publish.sh + a CI publish job that splits every folder into its own
repo at git.devai.io/templates/<folder>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01128fhuZbgivaSJvtMf4s1G
2026-09-27 21:10:38 +02:00

134 lines
3.8 KiB
Go

package main
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"golang.org/x/crypto/bcrypt"
)
type ctxKey struct{}
// userID returns the authenticated user id ("sub" claim, an ObjectID hex
// string) set by requireAuth.
func userID(r *http.Request) string {
v, _ := r.Context().Value(ctxKey{}).(string)
return v
}
func (a *app) register(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := decode(w, r, &in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json body")
return
}
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
if !strings.Contains(in.Email, "@") {
writeErr(w, http.StatusBadRequest, "valid email required")
return
}
if len(in.Password) < 8 {
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil { // bcrypt rejects passwords longer than 72 bytes
writeErr(w, http.StatusBadRequest, "password too long")
return
}
res, err := a.users.InsertOne(r.Context(), bson.M{
"email": in.Email,
"password_hash": string(hash),
"created_at": mongoNow(),
})
if isDuplicate(err) {
writeErr(w, http.StatusConflict, "email already registered")
return
}
if err != nil {
internalErr(w, err)
return
}
id := res.InsertedID.(bson.ObjectID)
writeJSON(w, http.StatusCreated, map[string]any{"id": id.Hex(), "email": in.Email})
}
func (a *app) login(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := decode(w, r, &in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json body")
return
}
var u struct {
ID bson.ObjectID `bson:"_id"`
PasswordHash string `bson:"password_hash"`
}
err := a.users.FindOne(r.Context(),
bson.M{"email": strings.ToLower(strings.TrimSpace(in.Email))}).Decode(&u)
if errors.Is(err, mongo.ErrNoDocuments) {
writeErr(w, http.StatusUnauthorized, "invalid email or password")
return
}
if err != nil {
internalErr(w, err)
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Password)) != nil {
writeErr(w, http.StatusUnauthorized, "invalid email or password")
return
}
token, err := a.issueToken(u.ID.Hex())
if err != nil {
internalErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"token": token})
}
// issueToken signs an HS256 JWT with sub = user id, valid for 7 days.
func (a *app) issueToken(sub string) (string, error) {
now := time.Now()
return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": sub,
"iat": now.Unix(),
"exp": now.Add(7 * 24 * time.Hour).Unix(),
}).SignedString(a.secret)
}
// requireAuth guards a handler: it validates the Bearer token and makes the
// user id available through userID(r).
func (a *app) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || raw == "" {
writeErr(w, http.StatusUnauthorized, "missing bearer token")
return
}
token, err := jwt.Parse(raw,
func(*jwt.Token) (any, error) { return a.secret, nil },
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithExpirationRequired())
if err != nil {
writeErr(w, http.StatusUnauthorized, "invalid or expired token")
return
}
sub, err := token.Claims.GetSubject()
if err != nil || sub == "" {
writeErr(w, http.StatusUnauthorized, "invalid token subject")
return
}
next(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, sub)))
}
}