543 lines
18 KiB
Markdown
543 lines
18 KiB
Markdown
# 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.
|