Files
2026-07-16 10:13:46 +03:30

210 lines
7.6 KiB
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"
)
// GoogleOAuthHandler implements "Sign in with Google" using the OAuth2
// Authorization Code flow:
//
// 1. GET /auth/google/login - we redirect the browser to Google.
// 2. User logs into Google and approves access, entirely on Google's own
// site - our server is not involved in that step at all.
// 3. GET /auth/google/callback - Google redirects the browser back to us
// with a temporary ?code=..., which we exchange (server-to-server,
// never visible to the browser) for an access token, then use that
// token to ask Google who the user is.
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}
}
// oauthStateSessionKey is where we temporarily stash the CSRF-protection
// "state" value between the /login redirect and the /callback request.
const oauthStateSessionKey = "oauth_state"
// Login handles GET /auth/google/login: builds Google's consent-screen URL
// and redirects the browser there.
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
}
// We store "state" in the visitor's session (not a package-level
// variable!) so it's correctly scoped per-visitor even with many
// concurrent users starting the login flow at the same time. It
// survives because the session cookie is already set on the browser
// before login - sessions work for anonymous visitors too, they just
// don't have a UserIDKey set yet.
h.sessions.Put(r.Context(), oauthStateSessionKey, state)
// AuthCodeURL builds the full URL to Google's consent screen,
// embedding our client ID, redirect URL, requested scopes, and state.
url := h.config.AuthCodeURL(state)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
// Callback handles GET /auth/google/callback: Google redirects the browser
// here with ?state=...&code=... after the user approves access.
func (h *GoogleOAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
// CSRF protection: confirm the state Google sent back matches the one
// WE generated for this specific login attempt. Without this check, an
// attacker could craft their own callback link using their own
// Google account and trick a victim into using it, potentially linking
// the attacker's Google account to the victim's session.
expectedState := h.sessions.GetString(r.Context(), oauthStateSessionKey)
if expectedState == "" || r.URL.Query().Get("state") != expectedState {
writeError(w, http.StatusBadRequest, "invalid oauth state")
return
}
// The state value is single-use - remove it from the session now that
// we've validated it, so it can't be replayed.
h.sessions.Remove(r.Context(), oauthStateSessionKey)
code := r.URL.Query().Get("code")
if code == "" {
writeError(w, http.StatusBadRequest, "missing code")
return
}
// Server-to-server call to Google: exchange the temporary, single-use
// code for a real access token. This request includes our
// ClientSecret, proving to Google that it's really our registered
// application making the request.
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
}
// h.config.Client returns a regular *http.Client, pre-configured to
// automatically attach the access token as an "Authorization: Bearer
// ..." header on every request it makes - no manual header handling
// needed.
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
}
// Same session-fixation defense and "store only the ID" pattern as
// the password-based Login handler in auth.go.
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 account.
// There are three possible cases:
//
// 1. No user exists with this email yet -> create a brand new user,
// Google-only (empty PasswordHash).
// 2. A user already exists with this email AND already has this
// Google account linked -> just return them.
// 3. A user already exists with this email but registered via password
// (no GoogleID yet) -> link this Google account to that existing
// user, so they can log in either way going forward.
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 intentionally left empty - this user can only
// log in via Google unless they later set a password (not
// implemented in this learning project, but would be a
// natural next feature).
}
if createErr := h.userRepo.Create(r.Context(), newUser); createErr != nil {
return nil, createErr
}
return newUser, nil
}
if err != nil {
return nil, err
}
// A user with this email already exists. 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
}
// generateState creates a cryptographically random, URL-safe string used
// as the OAuth2 "state" CSRF-protection parameter.
//
// Note this uses crypto/rand, NOT math/rand - crypto/rand is suitable for
// security-sensitive randomness (unpredictable even to an attacker who
// knows previous outputs), while math/rand is not.
func generateState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}