feat: fb and google oauth
This commit is contained in:
@@ -65,6 +65,18 @@ type Config struct {
|
||||
|
||||
// Frontend
|
||||
FrontendURL string // for CORS + reset/verify links in emails
|
||||
|
||||
// BFF public URL — the externally-reachable origin of this server. Used
|
||||
// to build the OAuth redirect_uri the provider sees (must match the
|
||||
// allow-list configured in the provider's console). In dev this is
|
||||
// usually http://localhost:8080.
|
||||
BFFPublicURL string
|
||||
|
||||
// OAuth providers — empty creds disable the corresponding provider.
|
||||
GoogleOAuthClientID string
|
||||
GoogleOAuthClientSecret string
|
||||
FacebookOAuthClientID string
|
||||
FacebookOAuthClientSecret string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables (with .env fallback).
|
||||
@@ -116,6 +128,13 @@ func Load() (*Config, error) {
|
||||
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
||||
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
|
||||
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
|
||||
|
||||
GoogleOAuthClientID: getEnv("GOOGLE_OAUTH_CLIENT_ID", ""),
|
||||
GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""),
|
||||
FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""),
|
||||
FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""),
|
||||
}
|
||||
|
||||
if cfg.MongoURI == "" {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// stateCookieName is the short-lived cookie that pairs with the state query
|
||||
// param so callback can verify "this redirect was started by us." Scoped to
|
||||
// /api/v1/auth/oauth so it never leaks into other handlers.
|
||||
const stateCookieName = "spsays_oauth_state"
|
||||
const stateCookieMaxAge = 10 * 60 // 10 minutes — matches stateMaxAge in pkg/oauth/state.go
|
||||
|
||||
// OAuthHandler exposes the GET /api/v1/auth/oauth/{provider}/start and
|
||||
// /callback endpoints. It composes Provider → token exchange → service.SignInWithOAuth.
|
||||
type OAuthHandler struct {
|
||||
svc *service.AuthService
|
||||
cookies auth.CookieOptions
|
||||
providers oauth.Registry
|
||||
stateSecret []byte // HMAC key for signing state — derived from JWT secret
|
||||
bffPublicURL string // e.g. http://localhost:8080 or https://api.prabhupadasays.org
|
||||
frontendURL string // e.g. http://localhost:3000 or https://prabhupadasays.org
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewOAuthHandler(
|
||||
svc *service.AuthService,
|
||||
cookies auth.CookieOptions,
|
||||
providers oauth.Registry,
|
||||
stateSecret []byte,
|
||||
bffPublicURL, frontendURL string,
|
||||
logger *zap.Logger,
|
||||
) *OAuthHandler {
|
||||
return &OAuthHandler{
|
||||
svc: svc,
|
||||
cookies: cookies,
|
||||
providers: providers,
|
||||
stateSecret: stateSecret,
|
||||
bffPublicURL: strings.TrimRight(bffPublicURL, "/"),
|
||||
frontendURL: strings.TrimRight(frontendURL, "/"),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Start kicks off the OAuth round-trip. It generates a signed state value,
|
||||
// stores it in a short-lived cookie, and 302s the browser to the provider's
|
||||
// AuthCodeURL. `next` query param carries the post-login redirect path on
|
||||
// the frontend (defaults to "/u/me").
|
||||
func (h *OAuthHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
prov, err := h.providers.Get(providerName)
|
||||
if err != nil {
|
||||
http.Error(w, "unknown provider", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
next := sanitizeNext(r.URL.Query().Get("next"))
|
||||
|
||||
state, err := oauth.EncodeState(h.stateSecret, providerName, next)
|
||||
if err != nil {
|
||||
h.logger.Error("oauth encode state", zap.Error(err))
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// State cookie matches the auth cookie's SameSite/Secure profile so the
|
||||
// browser sends it back on the callback redirect. Path-scoped so it
|
||||
// doesn't ride along on unrelated requests.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: state,
|
||||
Path: "/api/v1/auth/oauth",
|
||||
Domain: h.cookies.Domain,
|
||||
MaxAge: stateCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: h.cookies.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
cfg := prov.Config(h.callbackURL(providerName))
|
||||
authURL := cfg.AuthCodeURL(state)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// Callback finishes the round-trip. Verifies state, exchanges the code,
|
||||
// fetches the profile, signs the user in, sets the session cookie, and
|
||||
// redirects back to the frontend `next` path.
|
||||
func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
prov, err := h.providers.Get(providerName)
|
||||
if err != nil {
|
||||
h.redirectErr(w, r, "/login?oauth_error=unknown_provider")
|
||||
return
|
||||
}
|
||||
|
||||
// Provider returned an error (user denied, etc.).
|
||||
if e := r.URL.Query().Get("error"); e != "" {
|
||||
h.redirectErr(w, r, "/login?oauth_error="+url.QueryEscape(e))
|
||||
return
|
||||
}
|
||||
|
||||
// State must be present in BOTH cookie and query param, and they must
|
||||
// match. Either alone is not sufficient (cookie alone → no CSRF binding;
|
||||
// query alone → an attacker could craft any signed state).
|
||||
cookie, err := r.Cookie(stateCookieName)
|
||||
if err != nil {
|
||||
h.redirectErr(w, r, "/login?oauth_error=missing_state")
|
||||
return
|
||||
}
|
||||
stateQ := r.URL.Query().Get("state")
|
||||
if stateQ == "" || stateQ != cookie.Value {
|
||||
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||
return
|
||||
}
|
||||
// Clear the state cookie regardless of what happens next.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: "",
|
||||
Path: "/api/v1/auth/oauth",
|
||||
Domain: h.cookies.Domain,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
Secure: h.cookies.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
stateProvider, next, err := oauth.DecodeState(h.stateSecret, stateQ)
|
||||
if err != nil || stateProvider != providerName {
|
||||
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
h.redirectErr(w, r, "/login?oauth_error=missing_code")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := prov.Config(h.callbackURL(providerName))
|
||||
tok, err := cfg.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
h.logger.Warn("oauth code exchange failed", zap.String("provider", providerName), zap.Error(err))
|
||||
h.redirectErr(w, r, "/login?oauth_error=exchange_failed")
|
||||
return
|
||||
}
|
||||
|
||||
prof, err := prov.FetchProfile(r.Context(), tok)
|
||||
if err != nil {
|
||||
h.logger.Warn("oauth fetch profile failed", zap.String("provider", providerName), zap.Error(err))
|
||||
h.redirectErr(w, r, "/login?oauth_error=profile_failed")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.SignInWithOAuth(r.Context(), service.OAuthProfile{
|
||||
Provider: prof.Provider,
|
||||
ProviderUserID: prof.ProviderUserID,
|
||||
Email: prof.Email,
|
||||
EmailVerified: prof.EmailVerified,
|
||||
Name: prof.Name,
|
||||
AvatarURL: prof.AvatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrOAuthEmailMissing):
|
||||
h.redirectErr(w, r, "/login?oauth_error=email_required")
|
||||
case errors.Is(err, service.ErrOAuthUnavailable):
|
||||
h.redirectErr(w, r, "/login?oauth_error=unavailable")
|
||||
default:
|
||||
h.logger.Error("oauth sign-in", zap.Error(err))
|
||||
h.redirectErr(w, r, "/login?oauth_error=server")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||
http.Redirect(w, r, h.frontendURL+next, http.StatusFound)
|
||||
}
|
||||
|
||||
// callbackURL composes the redirect_uri the provider's console must allow-list.
|
||||
func (h *OAuthHandler) callbackURL(provider string) string {
|
||||
return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback"
|
||||
}
|
||||
|
||||
// redirectErr sends the user back to the frontend with a single error query
|
||||
// param so the login page can render something meaningful. No internal details
|
||||
// leak through this path.
|
||||
func (h *OAuthHandler) redirectErr(w http.ResponseWriter, r *http.Request, path string) {
|
||||
http.Redirect(w, r, h.frontendURL+path, http.StatusFound)
|
||||
}
|
||||
|
||||
// sanitizeNext bounds the post-login redirect to same-origin paths. Anything
|
||||
// containing a scheme, host, or whitespace is dropped to the default. This is
|
||||
// the same anti-open-redirect treatment we apply on the share-shortener.
|
||||
func sanitizeNext(raw string) string {
|
||||
const def = "/u/me"
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||||
return def
|
||||
}
|
||||
if strings.ContainsAny(raw, " \t\r\n") {
|
||||
return def
|
||||
}
|
||||
if len(raw) > 512 {
|
||||
return def
|
||||
}
|
||||
return raw
|
||||
}
|
||||
+19
-2
@@ -5,7 +5,9 @@ import "time"
|
||||
// User represents a row in the `users` table.
|
||||
//
|
||||
// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling
|
||||
// even if a caller accidentally passes a User through response.JSON.
|
||||
// even if a caller accidentally passes a User through response.JSON. It's a
|
||||
// pointer because OAuth-only users have no password — *PasswordHash == nil
|
||||
// means "this account can only sign in via a linked OAuth provider".
|
||||
//
|
||||
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
|
||||
// when the user has not yet verified, instead of an omitted field — the
|
||||
@@ -19,7 +21,7 @@ type User struct {
|
||||
Role string `json:"role"`
|
||||
Plan string `json:"plan"` // "free" | "premium"
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||
PasswordHash string `json:"-"`
|
||||
PasswordHash *string `json:"-"`
|
||||
TokenVersion int `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -42,6 +44,21 @@ const (
|
||||
PurposeEmailVerification = "email_verification"
|
||||
)
|
||||
|
||||
// OAuthAccount represents a row in the `oauth_accounts` table — one entry
|
||||
// per (provider, provider_user_id). A single user can have several linked
|
||||
// providers.
|
||||
type OAuthAccount struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderUserID string `json:"provider_user_id"`
|
||||
Email *string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ShortURL represents a shortened URL.
|
||||
type ShortURL struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// ErrOAuthAccountNotFound mirrors the other repo sentinels.
|
||||
var ErrOAuthAccountNotFound = errors.New("oauth account not found")
|
||||
|
||||
// OAuthAccountRepo manages provider linkage rows.
|
||||
type OAuthAccountRepo struct {
|
||||
pg *Postgres
|
||||
}
|
||||
|
||||
func NewOAuthAccountRepo(pg *Postgres) *OAuthAccountRepo {
|
||||
return &OAuthAccountRepo{pg: pg}
|
||||
}
|
||||
|
||||
// FindByProvider returns the linkage row for the given (provider, provider_user_id),
|
||||
// or ErrOAuthAccountNotFound. Use this first on every callback — a hit means
|
||||
// this provider identity has been seen before and we just log the user in.
|
||||
func (r *OAuthAccountRepo) FindByProvider(ctx context.Context, provider, providerUserID string) (*model.OAuthAccount, error) {
|
||||
const q = `
|
||||
SELECT id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at
|
||||
FROM oauth_accounts
|
||||
WHERE provider = $1 AND provider_user_id = $2
|
||||
LIMIT 1`
|
||||
a := &model.OAuthAccount{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, provider, providerUserID).Scan(
|
||||
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
|
||||
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrOAuthAccountNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("find oauth account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Link inserts a new oauth_accounts row tying a provider identity to an
|
||||
// existing user. Idempotent: a second call for the same (provider, sub)
|
||||
// returns the existing row instead of erroring on the unique constraint.
|
||||
func (r *OAuthAccountRepo) Link(ctx context.Context, userID, provider, providerUserID string, email, name, avatarURL *string) (*model.OAuthAccount, error) {
|
||||
const q = `
|
||||
INSERT INTO oauth_accounts (user_id, provider, provider_user_id, email, name, avatar_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (provider, provider_user_id) DO UPDATE
|
||||
SET email = EXCLUDED.email,
|
||||
name = EXCLUDED.name,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
updated_at = now()
|
||||
RETURNING id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at`
|
||||
a := &model.OAuthAccount{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, userID, provider, providerUserID, email, name, avatarURL).Scan(
|
||||
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
|
||||
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("link oauth account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@@ -62,6 +62,32 @@ func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User,
|
||||
`WHERE email = $1`, strings.ToLower(strings.TrimSpace(email)))
|
||||
}
|
||||
|
||||
// CreateOAuth inserts a user with a NULL password_hash — used by the OAuth
|
||||
// sign-in flow when there's no matching email yet. Same handle-collision
|
||||
// retry contract as Create.
|
||||
func (r *UserRepo) CreateOAuth(ctx context.Context, email, name, handle string, avatarURL *string, emailVerified bool) (*model.User, error) {
|
||||
const q = `
|
||||
INSERT INTO users (email, password_hash, name, handle, avatar_url, email_verified_at)
|
||||
VALUES ($1, NULL, $2, $3, $4, CASE WHEN $5::boolean THEN now() ELSE NULL END)
|
||||
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at`
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q,
|
||||
strings.ToLower(strings.TrimSpace(email)), name, handle, avatarURL, emailVerified,
|
||||
).Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err, "users_handle_key") {
|
||||
return nil, ErrHandleTaken
|
||||
}
|
||||
if isUniqueViolation(err, "users_email_key") {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
return nil, fmt.Errorf("create oauth user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// FindByID returns the user with the given id, or ErrUserNotFound.
|
||||
func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) {
|
||||
return r.findOne(ctx, `WHERE id = $1`, id)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -45,6 +46,7 @@ type Server struct {
|
||||
searchH *handler.SearchHandler
|
||||
shortURLH *handler.ShortURLHandler
|
||||
authH *handler.AuthHandler
|
||||
oauthH *handler.OAuthHandler
|
||||
userH *handler.UserHandler
|
||||
postH *handler.PostHandler
|
||||
bookmarkH *handler.BookmarkHandler
|
||||
@@ -121,6 +123,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
|
||||
userRepo := repository.NewUserRepo(s.pg)
|
||||
tokenRepo := repository.NewAuthTokenRepo(s.pg)
|
||||
oauthRepo := repository.NewOAuthAccountRepo(s.pg)
|
||||
|
||||
r2, err := repository.NewR2(repository.R2Config{
|
||||
AccountID: cfg.R2AccountID,
|
||||
@@ -145,7 +148,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
From: cfg.SMTPFrom,
|
||||
}, logger)
|
||||
|
||||
authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL)
|
||||
authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL)
|
||||
userSvc := service.NewUserService(userRepo, r2)
|
||||
|
||||
// Social repositories + services share the same Postgres pool and R2 client.
|
||||
@@ -162,6 +165,25 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
s.userRepo = userRepo
|
||||
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
||||
s.userH = handler.NewUserHandler(userSvc)
|
||||
|
||||
// OAuth — wired only when at least one provider is configured. The
|
||||
// registry skips providers with missing creds, so partial configs (e.g.
|
||||
// Google only, no Facebook) work out of the box.
|
||||
providers := oauth.NewRegistry(oauth.Credentials{
|
||||
GoogleClientID: cfg.GoogleOAuthClientID,
|
||||
GoogleClientSecret: cfg.GoogleOAuthClientSecret,
|
||||
FacebookClientID: cfg.FacebookOAuthClientID,
|
||||
FacebookClientSecret: cfg.FacebookOAuthClientSecret,
|
||||
})
|
||||
if len(providers) > 0 {
|
||||
s.oauthH = handler.NewOAuthHandler(
|
||||
authSvc, cookieOpts, providers,
|
||||
[]byte(cfg.JWTSecret), cfg.BFFPublicURL, cfg.FrontendURL,
|
||||
logger,
|
||||
)
|
||||
} else {
|
||||
logger.Info("oauth: no providers configured — /auth/oauth/* will 404")
|
||||
}
|
||||
s.postH = handler.NewPostHandler(postSvc)
|
||||
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
|
||||
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
|
||||
@@ -486,6 +508,15 @@ func (s *Server) setupRouter() {
|
||||
r.Post("/reset-password", s.authH.ResetPassword)
|
||||
r.Post("/verify-email", s.authH.VerifyEmail)
|
||||
|
||||
// OAuth (Google, Facebook, …). GET endpoints because the browser
|
||||
// navigates directly to /start (top-level redirect) and the
|
||||
// provider redirects back to /callback. Both flows set cookies
|
||||
// and 302 — they never return JSON.
|
||||
if s.oauthH != nil {
|
||||
r.Get("/oauth/{provider}/start", s.oauthH.Start)
|
||||
r.Get("/oauth/{provider}/callback", s.oauthH.Callback)
|
||||
}
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/me", s.authH.Me)
|
||||
|
||||
@@ -39,6 +39,7 @@ var (
|
||||
type AuthService struct {
|
||||
users *repository.UserRepo
|
||||
tokens *repository.AuthTokenRepo
|
||||
oauth *repository.OAuthAccountRepo // optional — nil disables OAuth flows
|
||||
jwt *auth.Issuer
|
||||
mail mailer.Mailer
|
||||
logger *zap.Logger
|
||||
@@ -49,10 +50,12 @@ type AuthService struct {
|
||||
EmailVerifyTTL time.Duration
|
||||
}
|
||||
|
||||
// NewAuthService wires the dependencies.
|
||||
// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if
|
||||
// the OAuth flows aren't wired (handler then returns 503).
|
||||
func NewAuthService(
|
||||
users *repository.UserRepo,
|
||||
tokens *repository.AuthTokenRepo,
|
||||
oauthRepo *repository.OAuthAccountRepo,
|
||||
jwt *auth.Issuer,
|
||||
mail mailer.Mailer,
|
||||
logger *zap.Logger,
|
||||
@@ -61,6 +64,7 @@ func NewAuthService(
|
||||
return &AuthService{
|
||||
users: users,
|
||||
tokens: tokens,
|
||||
oauth: oauthRepo,
|
||||
jwt: jwt,
|
||||
mail: mail,
|
||||
logger: logger,
|
||||
@@ -136,12 +140,135 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthR
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
// OAuth-only accounts have no password hash. Treat them as a credential
|
||||
// mismatch so we don't reveal that an OAuth user exists for this email.
|
||||
if u.PasswordHash == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return s.issueSession(u)
|
||||
}
|
||||
|
||||
// OAuthProfile is the provider-agnostic profile shape consumed by
|
||||
// SignInWithOAuth. Mirrors pkg/oauth.Profile but lives here so the service
|
||||
// layer doesn't import the OAuth package (which would create a layering
|
||||
// cycle once handlers compose both).
|
||||
type OAuthProfile struct {
|
||||
Provider string
|
||||
ProviderUserID string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
Name string
|
||||
AvatarURL string
|
||||
}
|
||||
|
||||
// ErrOAuthEmailMissing is returned by SignInWithOAuth when the provider did
|
||||
// not give us an email and the account has no linkage we could match on.
|
||||
// Without an email we can't safely create a new user (since later sign-ins
|
||||
// via email/password would have no way to converge with this account).
|
||||
var ErrOAuthEmailMissing = errors.New("oauth provider did not return an email")
|
||||
|
||||
// ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at
|
||||
// startup). Handler maps to 503.
|
||||
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
|
||||
|
||||
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
|
||||
//
|
||||
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
|
||||
// 2. Existing user with same email → link this provider to them, log in.
|
||||
// 3. Otherwise create a new user (no password, email_verified iff provider
|
||||
// said so) and link.
|
||||
//
|
||||
// In every case the resulting user gets a fresh JWT just like LogIn.
|
||||
func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*AuthResult, error) {
|
||||
if s.oauth == nil {
|
||||
return nil, ErrOAuthUnavailable
|
||||
}
|
||||
|
||||
// 1. Known linkage.
|
||||
if a, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
|
||||
u, err := s.users.FindByID(ctx, a.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Refresh denormalised fields on the linkage row (display name /
|
||||
// avatar may have changed at the provider).
|
||||
_, _ = s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||
return s.issueSession(u)
|
||||
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// No linkage yet — we need at least an email to safely converge with an
|
||||
// existing account or create a new one.
|
||||
if prof.Email == "" {
|
||||
return nil, ErrOAuthEmailMissing
|
||||
}
|
||||
|
||||
// 2. Existing user with the same email → link.
|
||||
if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil {
|
||||
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.issueSession(u)
|
||||
} else if !errors.Is(err, repository.ErrUserNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Brand new user. Reuse the handle-collision retry loop.
|
||||
displayName := prof.Name
|
||||
if displayName == "" {
|
||||
displayName = strings.SplitN(prof.Email, "@", 2)[0]
|
||||
}
|
||||
base := slugForHandle(prof.Email)
|
||||
var u *model.User
|
||||
var err error
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
handle := base
|
||||
if attempt > 0 {
|
||||
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
|
||||
}
|
||||
u, err = s.users.CreateOAuth(ctx, prof.Email, displayName, handle, optStr(prof.AvatarURL), prof.EmailVerified)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, repository.ErrEmailTaken) {
|
||||
// Race: someone else just claimed this email. Fall back to step 2.
|
||||
if u2, err2 := s.users.FindByEmail(ctx, prof.Email); err2 == nil {
|
||||
_, _ = s.oauth.Link(ctx, u2.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||
return s.issueSession(u2)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !errors.Is(err, repository.ErrHandleTaken) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create oauth user: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.issueSession(u)
|
||||
}
|
||||
|
||||
// optStr returns nil for empty input, &s otherwise — used to populate
|
||||
// nullable text columns from possibly-empty profile fields.
|
||||
func optStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// Me looks up a user by id and re-verifies the JWT's token_version against
|
||||
// the stored value. Mismatch means the user has logged-out-everywhere or
|
||||
// changed password since this JWT was issued → reject.
|
||||
|
||||
Reference in New Issue
Block a user