first commit
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
# Lesson 7 — Login with Google (OAuth2)
|
||||
|
||||
> **New Go concepts in this lesson:** anonymous structs for one-off JSON
|
||||
> shapes, `crypto/rand` vs `math/rand`, working with an `*http.Client`
|
||||
> returned by a library. Builds on JSON basics and error handling from
|
||||
> the Go Basics lessons — nothing fundamentally new at the language
|
||||
> level, but a new external flow (OAuth2) to understand conceptually.
|
||||
|
||||
## What OAuth2 actually does here
|
||||
|
||||
Your app never sees the user's Google password. Instead, your app
|
||||
redirects the user to Google, the user logs into *Google* and approves
|
||||
"let this app see your email," and Google redirects back to your app with
|
||||
a temporary code. Your app exchanges that code (server-to-server, never
|
||||
visible to the browser) for an access token, then uses that token to ask
|
||||
Google "who is this user?" This is called the **Authorization Code
|
||||
flow** — the standard, secure OAuth2 pattern.
|
||||
|
||||
## Setting up Google credentials (one-time, outside the code)
|
||||
|
||||
1. Go to the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create a project if needed.
|
||||
2. Create an **OAuth 2.0 Client ID** (Application type: Web application).
|
||||
3. Add an **Authorized redirect URI**: `http://localhost:8080/auth/google/callback`.
|
||||
4. You'll get a **Client ID** and **Client Secret** — treat the secret
|
||||
like a password, never commit it to git.
|
||||
|
||||
## Part A — standalone playground
|
||||
|
||||
```bash
|
||||
mkdir ~/go-playground/oauth-demo && cd ~/go-playground/oauth-demo
|
||||
go mod init oauth-demo
|
||||
go get golang.org/x/oauth2@latest
|
||||
```
|
||||
|
||||
**`main.go`**
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
)
|
||||
|
||||
// 1. oauth2.Config describes everything needed to talk to Google's OAuth
|
||||
// system. Fill in your own Client ID/Secret from the Cloud Console.
|
||||
var googleOAuthConfig = &oauth2.Config{
|
||||
ClientID: "YOUR_CLIENT_ID.apps.googleusercontent.com",
|
||||
ClientSecret: "YOUR_CLIENT_SECRET",
|
||||
RedirectURL: "http://localhost:4000/auth/google/callback",
|
||||
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
|
||||
Endpoint: google.Endpoint,
|
||||
}
|
||||
|
||||
// In real code this state should be stored per-session, not a global -
|
||||
// we use a global here ONLY to keep this playground minimal.
|
||||
var expectedState string
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/login", loginHandler)
|
||||
http.HandleFunc("/auth/google/callback", callbackHandler)
|
||||
|
||||
log.Println("visit http://localhost:4000/login")
|
||||
log.Fatal(http.ListenAndServe(":4000", nil))
|
||||
}
|
||||
|
||||
func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 2. Generate a random "state" value - CSRF protection: we'll check
|
||||
// that the state Google sends back matches what we generated, proving
|
||||
// the callback really came from a login WE initiated.
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to generate state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
expectedState = state
|
||||
|
||||
// 3. AuthCodeURL builds the full URL to Google's consent screen,
|
||||
// embedding our client ID, redirect URL, scopes, and state.
|
||||
url := googleOAuthConfig.AuthCodeURL(state)
|
||||
|
||||
// 4. Send the browser there.
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func callbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 5. Google redirects back here with ?state=...&code=... in the URL.
|
||||
if r.URL.Query().Get("state") != expectedState {
|
||||
http.Error(w, "invalid state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "missing code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 6. Exchange the temporary code for an actual access token. This is
|
||||
// a direct server-to-server HTTPS call to Google - the code is
|
||||
// single-use and expires quickly.
|
||||
token, err := googleOAuthConfig.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
http.Error(w, "code exchange failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 7. Use the access token to call Google's userinfo endpoint and find
|
||||
// out who actually logged in.
|
||||
client := googleOAuthConfig.Client(r.Context(), token)
|
||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
if err != nil {
|
||||
http.Error(w, "failed to fetch user info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read user info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var googleUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &googleUser); err != nil {
|
||||
http.Error(w, "failed to parse user info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "logged in as: id=%s email=%s\n", googleUser.ID, googleUser.Email)
|
||||
}
|
||||
|
||||
func generateState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
```
|
||||
|
||||
Run it, plug in your real Client ID/Secret, then visit
|
||||
`http://localhost:4000/login` **in a real browser** (curl can't drive
|
||||
Google's login page).
|
||||
|
||||
Line by line:
|
||||
|
||||
- `oauth2.Config` — a struct holding everything needed to run the flow:
|
||||
your app's identity (`ClientID`/`ClientSecret`), where Google sends the
|
||||
user back (`RedirectURL` — **must exactly match** the Cloud Console
|
||||
registration), what data you're requesting (`Scopes`), and which
|
||||
provider's endpoints to use (`Endpoint: google.Endpoint`, a predefined
|
||||
value pointing at Google's real auth/token URLs).
|
||||
- `var googleUser struct { ID string \`json:"id"\`; Email string \`json:"email"\` }`
|
||||
— this is an **anonymous struct**: a struct type defined inline,
|
||||
without a `type Name struct` declaration, used because we only need
|
||||
this shape once, right here, to decode one specific JSON response.
|
||||
- `generateState()` — `crypto/rand` (NOT `math/rand`!) generates
|
||||
cryptographically secure random bytes, unpredictable even if an
|
||||
attacker knows previous outputs. This matters because `state` is a
|
||||
security mechanism, not just a random label. `math/rand` is fine for
|
||||
games/simulations, never for anything security-sensitive.
|
||||
- **Why `state` matters**: without it, an attacker could craft their own
|
||||
malicious callback link using *their own* Google account's code, trick
|
||||
a victim into clicking it while logged into your app, and potentially
|
||||
link the attacker's Google account to the victim's session. Checking
|
||||
that `state` matches what *we* generated closes that hole.
|
||||
- `googleOAuthConfig.AuthCodeURL(state)` — builds the actual Google
|
||||
consent-screen URL; you never hand-construct this.
|
||||
- `http.Redirect(w, r, url, http.StatusTemporaryRedirect)` — sends an
|
||||
HTTP 302-style response telling the browser "go here instead." The
|
||||
browser follows it to Google.
|
||||
- `googleOAuthConfig.Exchange(r.Context(), code)` — server-to-server: your
|
||||
Go program makes an HTTPS POST to Google, presenting `code` plus your
|
||||
`ClientSecret` (proving it's really your registered app), and gets back
|
||||
an `oauth2.Token`.
|
||||
- `googleOAuthConfig.Client(r.Context(), token)` — returns a regular
|
||||
`*http.Client`, pre-configured to automatically attach the access token
|
||||
as an `Authorization: Bearer ...` header on every request — no manual
|
||||
header handling needed.
|
||||
- `io.ReadAll(resp.Body)` then `json.Unmarshal(body, &googleUser)` —
|
||||
slightly different from `json.NewDecoder(r.Body).Decode(&x)` used
|
||||
elsewhere. Both work; `Unmarshal` needs the full byte slice up front
|
||||
(hence `ReadAll` first), `Decode` streams directly. Either is fine for
|
||||
small responses — you'll see both styles in real Go code.
|
||||
- `defer resp.Body.Close()` — same rule as `defer rows.Close()` from
|
||||
Lesson 3: any "reader" resource (HTTP body, SQL rows, open file) should
|
||||
always be closed when you're done with it.
|
||||
|
||||
Try it end to end, confirm you see your real Google email printed back.
|
||||
Try mangling the `state` value in the URL manually and confirm "invalid
|
||||
state."
|
||||
|
||||
## Part B — apply it to the project
|
||||
|
||||
**Add the dependency:**
|
||||
```bash
|
||||
go get golang.org/x/oauth2@latest
|
||||
```
|
||||
|
||||
**Extend `internal/config/config.go`:**
|
||||
```go
|
||||
type Config struct {
|
||||
Port string
|
||||
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
|
||||
RedisAddr string
|
||||
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
GoogleRedirectURL string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
|
||||
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||
DBPort: getEnv("DB_PORT", "3306"),
|
||||
DBUser: getEnv("DB_USER", "root"),
|
||||
DBPassword: getEnv("DB_PASSWORD", "devpass"),
|
||||
DBName: getEnv("DB_NAME", "go_simple_api"),
|
||||
|
||||
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
|
||||
|
||||
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
||||
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
||||
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/google/callback"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add to `.env`:
|
||||
```
|
||||
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=your-client-secret
|
||||
GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback
|
||||
```
|
||||
|
||||
**`internal/oauth/google.go`** — builds the `*oauth2.Config` from our
|
||||
app's `Config`:
|
||||
```go
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||
)
|
||||
|
||||
func NewGoogleConfig(cfg config.Config) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: cfg.GoogleClientID,
|
||||
ClientSecret: cfg.GoogleClientSecret,
|
||||
RedirectURL: cfg.GoogleRedirectURL,
|
||||
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
|
||||
Endpoint: google.Endpoint,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`internal/handlers/oauth_google.go`** — the real handler, reusing the
|
||||
flow from Part A but wired into our session/repository system:
|
||||
```go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
|
||||
)
|
||||
|
||||
type GoogleOAuthHandler struct {
|
||||
config *oauth2.Config
|
||||
userRepo *models.UserRepository
|
||||
sessions *scs.SessionManager
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewGoogleOAuthHandler(config *oauth2.Config, userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *GoogleOAuthHandler {
|
||||
return &GoogleOAuthHandler{config: config, userRepo: userRepo, sessions: sessions, logger: logger}
|
||||
}
|
||||
|
||||
const oauthStateSessionKey = "oauth_state"
|
||||
|
||||
func (h *GoogleOAuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
h.logger.Error("generate oauth state failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// Store state in the SESSION instead of a global variable (Part A cut
|
||||
// this corner deliberately, having no session system yet). This
|
||||
// means state survives correctly even with multiple users hitting
|
||||
// /auth/google/login concurrently.
|
||||
h.sessions.Put(r.Context(), oauthStateSessionKey, state)
|
||||
|
||||
url := h.config.AuthCodeURL(state)
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (h *GoogleOAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
expectedState := h.sessions.GetString(r.Context(), oauthStateSessionKey)
|
||||
if expectedState == "" || r.URL.Query().Get("state") != expectedState {
|
||||
writeError(w, http.StatusBadRequest, "invalid oauth state")
|
||||
return
|
||||
}
|
||||
h.sessions.Remove(r.Context(), oauthStateSessionKey) // one-time use
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing code")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.config.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
h.logger.Error("oauth exchange failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
client := h.config.Client(r.Context(), token)
|
||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
if err != nil {
|
||||
h.logger.Error("fetch google userinfo failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
h.logger.Error("read google userinfo failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
var googleUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &googleUser); err != nil {
|
||||
h.logger.Error("parse google userinfo failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.findOrCreateGoogleUser(r, googleUser.ID, googleUser.Email)
|
||||
if err != nil {
|
||||
h.logger.Error("find or create google user failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessions.RenewToken(r.Context()); err != nil {
|
||||
h.logger.Error("renew token failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
h.sessions.Put(r.Context(), session.UserIDKey, user.ID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
})
|
||||
}
|
||||
|
||||
// findOrCreateGoogleUser links a Google identity to a local user. Three
|
||||
// cases: an existing user with this email but no google_id yet (link
|
||||
// it), a user already linked to this google_id (just fetch it), or
|
||||
// nobody with this email exists yet (create a new user).
|
||||
func (h *GoogleOAuthHandler) findOrCreateGoogleUser(r *http.Request, googleID, email string) (*models.User, error) {
|
||||
existing, err := h.userRepo.FindByEmail(r.Context(), email)
|
||||
|
||||
if errors.Is(err, models.ErrUserNotFound) {
|
||||
newUser := &models.User{
|
||||
Email: email,
|
||||
GoogleID: googleID,
|
||||
// PasswordHash stays empty - this user can only log in via Google.
|
||||
}
|
||||
if createErr := h.userRepo.Create(r.Context(), newUser); createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
return newUser, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// User exists by email. If they haven't linked Google yet, link it now.
|
||||
if existing.GoogleID == "" {
|
||||
if linkErr := h.userRepo.SetGoogleID(r.Context(), existing.ID, googleID); linkErr != nil {
|
||||
return nil, linkErr
|
||||
}
|
||||
existing.GoogleID = googleID
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func generateState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
```
|
||||
|
||||
Notable differences from Part A, and why:
|
||||
|
||||
- **State stored in the session, not a global variable.** Part A's
|
||||
`var expectedState string` only works for one user at a time — a real
|
||||
server handles many concurrent users, and a shared global would let one
|
||||
user's login attempt clobber another's state. Storing it via
|
||||
`h.sessions.Put(...)` scopes it correctly per-visitor.
|
||||
- `h.sessions.Remove(...)` — a new scs method: removes a single key from
|
||||
the session (as opposed to `Destroy`, which wipes the whole session).
|
||||
`state` is only needed for this one round trip, so we clean it up
|
||||
immediately after checking it.
|
||||
- `h.findOrCreateGoogleUser(...)` — the **account linking** logic. Three
|
||||
distinct paths, each built entirely from repository methods you already
|
||||
know from Lesson 4/5.
|
||||
|
||||
**Add one more repository method — `internal/models/user_repository.go`:**
|
||||
```go
|
||||
func (r *UserRepository) SetGoogleID(ctx context.Context, userID int, googleID string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
"UPDATE users SET google_id = ? WHERE id = ?", googleID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set google id: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Update `internal/router/router.go`:**
|
||||
```go
|
||||
package router
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||||
"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"
|
||||
"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
|
||||
)
|
||||
|
||||
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *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.Use(sessions.LoadAndSave)
|
||||
|
||||
r.Get("/health", handlers.Health)
|
||||
|
||||
userRepo := models.NewUserRepository(db)
|
||||
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
|
||||
|
||||
r.Post("/register", authHandler.Register)
|
||||
r.Post("/login", authHandler.Login)
|
||||
r.Post("/logout", authHandler.Logout)
|
||||
r.Get("/me", authHandler.Me)
|
||||
|
||||
googleConfig := oauth.NewGoogleConfig(cfg)
|
||||
googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)
|
||||
|
||||
r.Get("/auth/google/login", googleHandler.Login)
|
||||
r.Get("/auth/google/callback", googleHandler.Callback)
|
||||
|
||||
return r
|
||||
}
|
||||
```
|
||||
`New` now also takes `cfg config.Config` (needed to build the Google
|
||||
OAuth config).
|
||||
|
||||
**Update `cmd/api/main.go`** — change the `router.New(...)` call:
|
||||
```go
|
||||
r := router.New(logger, db, sessions, cfg)
|
||||
```
|
||||
|
||||
## Try it
|
||||
|
||||
```bash
|
||||
go run ./cmd/api
|
||||
```
|
||||
Visit `http://localhost:8080/auth/google/login` **in a browser**. After
|
||||
approving, you should land on `/auth/google/callback` and see JSON back
|
||||
with your id/email, plus a session cookie:
|
||||
```bash
|
||||
# copy the session_id cookie value from your browser's devtools
|
||||
curl -b "session_id=<paste-cookie-value-here>" http://localhost:8080/me
|
||||
```
|
||||
|
||||
Confirm in MySQL:
|
||||
```bash
|
||||
docker exec -it mysql-api mysql -uroot -pdevpass go_simple_api -e "SELECT id, email, google_id FROM users;"
|
||||
```
|
||||
|
||||
Once a full Google login round-trip works, move to Lesson 8 — auth
|
||||
middleware & route protection.
|
||||
Reference in New Issue
Block a user