first commit
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user