feat: account connect
This commit is contained in:
@@ -53,6 +53,12 @@ type verifyReq struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type verifyLoginOTPReq struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
OTP string `json:"otp"`
|
||||||
|
}
|
||||||
|
|
||||||
type setEmailReq struct {
|
type setEmailReq struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
@@ -92,7 +98,14 @@ func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.JSON(w, http.StatusCreated, res.User)
|
response.JSON(w, http.StatusCreated, res.User)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogIn authenticates an existing user.
|
// LogIn authenticates an existing user. The response is one of:
|
||||||
|
//
|
||||||
|
// - 200 OK with the User JSON — credentials verified, session cookie set,
|
||||||
|
// frontend treats this as fully signed in.
|
||||||
|
// - 202 Accepted with `{ "otp_required": true }` — credentials verified
|
||||||
|
// but the account has been unverified for over 30 days. An OTP has been
|
||||||
|
// emailed; frontend must collect it and call VerifyLoginOTP next. No
|
||||||
|
// session cookie is set on this branch.
|
||||||
func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
|
||||||
var req logInReq
|
var req logInReq
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -112,6 +125,40 @@ func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.renderAuthErr(w, err)
|
h.renderAuthErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if res.OTPRequired {
|
||||||
|
response.JSON(w, http.StatusAccepted, map[string]any{
|
||||||
|
"otp_required": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.Set(w, res.Auth.Token, res.Auth.Expires, h.cookies)
|
||||||
|
response.JSON(w, http.StatusOK, res.Auth.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyLoginOTP completes a login that was gated by the stale-unverified
|
||||||
|
// OTP path. Re-checks the password (the OTP alone isn't enough — an
|
||||||
|
// attacker who steals the OTP email still needs the password), validates
|
||||||
|
// the OTP, flips email_verified_at to now(), and sets the session cookie.
|
||||||
|
func (h *AuthHandler) VerifyLoginOTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req verifyLoginOTPReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := validator.New()
|
||||||
|
validator.Required(v, "email", req.Email)
|
||||||
|
validator.Required(v, "password", req.Password)
|
||||||
|
validator.Required(v, "otp", req.OTP)
|
||||||
|
if !v.Valid() {
|
||||||
|
response.Error(w, http.StatusBadRequest, v.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.svc.VerifyLoginOTP(r.Context(), req.Email, req.Password, req.OTP)
|
||||||
|
if err != nil {
|
||||||
|
h.renderAuthErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
auth.Set(w, res.Token, res.Expires, h.cookies)
|
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||||
response.JSON(w, http.StatusOK, res.User)
|
response.JSON(w, http.StatusOK, res.User)
|
||||||
}
|
}
|
||||||
@@ -260,6 +307,19 @@ func (h *AuthHandler) SetEmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.JSON(w, http.StatusOK, u)
|
response.JSON(w, http.StatusOK, u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinkedProviders — protected — returns the OAuth provider names already
|
||||||
|
// linked to the caller's account. Settings UI uses this to flip each
|
||||||
|
// social-connect row between "Connect" and "Connected".
|
||||||
|
func (h *AuthHandler) LinkedProviders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
providers, err := h.svc.LinkedProviders(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load linked providers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"providers": providers})
|
||||||
|
}
|
||||||
|
|
||||||
// ResendVerification — protected — refires the verification email for the
|
// ResendVerification — protected — refires the verification email for the
|
||||||
// currently-logged-in user.
|
// currently-logged-in user.
|
||||||
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||||
@@ -65,7 +66,7 @@ func (h *OAuthHandler) Start(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
next := sanitizeNext(r.URL.Query().Get("next"))
|
next := sanitizeNext(r.URL.Query().Get("next"))
|
||||||
|
|
||||||
state, err := oauth.EncodeState(h.stateSecret, providerName, next)
|
state, err := oauth.EncodeState(h.stateSecret, providerName, next, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("oauth encode state", zap.Error(err))
|
h.logger.Error("oauth encode state", zap.Error(err))
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -134,7 +135,7 @@ func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
stateProvider, next, err := oauth.DecodeState(h.stateSecret, stateQ)
|
stateProvider, next, linkUserID, err := oauth.DecodeState(h.stateSecret, stateQ)
|
||||||
if err != nil || stateProvider != providerName {
|
if err != nil || stateProvider != providerName {
|
||||||
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||||
return
|
return
|
||||||
@@ -161,6 +162,14 @@ func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// State pinned to a user id means the OAuth round-trip was started from
|
||||||
|
// /link/start (settings page). Dispatch to the link path so we link the
|
||||||
|
// provider to that user instead of running a fresh sign-in.
|
||||||
|
if linkUserID != "" {
|
||||||
|
h.finishLink(w, r, providerName, next, linkUserID, prof)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
res, err := h.svc.SignInWithOAuth(r.Context(), service.OAuthProfile{
|
res, err := h.svc.SignInWithOAuth(r.Context(), service.OAuthProfile{
|
||||||
Provider: prof.Provider,
|
Provider: prof.Provider,
|
||||||
ProviderUserID: prof.ProviderUserID,
|
ProviderUserID: prof.ProviderUserID,
|
||||||
@@ -184,6 +193,89 @@ func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, h.frontendURL+next, http.StatusFound)
|
http.Redirect(w, r, h.frontendURL+next, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinkStart kicks off the OAuth round-trip for an already-authenticated
|
||||||
|
// user who wants to add a provider to their account (settings → Connect).
|
||||||
|
// The caller's user id is signed into the state so the callback knows
|
||||||
|
// which account to link to instead of trying to find one by email.
|
||||||
|
func (h *OAuthHandler) LinkStart(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
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
if uid == "" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := sanitizeNext(r.URL.Query().Get("next"))
|
||||||
|
|
||||||
|
state, err := oauth.EncodeState(h.stateSecret, providerName, next, uid)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("oauth encode link state", zap.Error(err))
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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, prov.AuthCodeOptions()...)
|
||||||
|
http.Redirect(w, r, authURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishLink completes the link flow once the shared Callback has decoded
|
||||||
|
// state and fetched the provider profile. Bounces back to the frontend
|
||||||
|
// `next` path with `?link=ok|claimed|error` so the settings UI can render
|
||||||
|
// a meaningful message.
|
||||||
|
func (h *OAuthHandler) finishLink(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
providerName, next, linkUserID string,
|
||||||
|
prof *oauth.Profile,
|
||||||
|
) {
|
||||||
|
err := h.svc.LinkOAuthToCurrentUser(r.Context(), linkUserID, service.OAuthProfile{
|
||||||
|
Provider: prof.Provider,
|
||||||
|
ProviderUserID: prof.ProviderUserID,
|
||||||
|
Email: prof.Email,
|
||||||
|
EmailVerified: prof.EmailVerified,
|
||||||
|
Name: prof.Name,
|
||||||
|
AvatarURL: prof.AvatarURL,
|
||||||
|
})
|
||||||
|
target := next
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
target = appendQuery(next, "link", providerName+":ok")
|
||||||
|
case errors.Is(err, service.ErrOAuthAccountClaimed):
|
||||||
|
target = appendQuery(next, "link", providerName+":claimed")
|
||||||
|
default:
|
||||||
|
h.logger.Error("oauth link", zap.String("provider", providerName), zap.Error(err))
|
||||||
|
target = appendQuery(next, "link", providerName+":error")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, h.frontendURL+target, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendQuery adds a ?key=val (or &key=val) to a same-origin path, preserving
|
||||||
|
// any existing query string. Path must start with "/" — caller guarantees.
|
||||||
|
func appendQuery(path, key, value string) string {
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(path, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
return path + sep + url.QueryEscape(key) + "=" + url.QueryEscape(value)
|
||||||
|
}
|
||||||
|
|
||||||
// callbackURL composes the redirect_uri the provider's console must allow-list.
|
// callbackURL composes the redirect_uri the provider's console must allow-list.
|
||||||
func (h *OAuthHandler) callbackURL(provider string) string {
|
func (h *OAuthHandler) callbackURL(provider string) string {
|
||||||
return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback"
|
return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback"
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ type AuthToken struct {
|
|||||||
const (
|
const (
|
||||||
PurposePasswordReset = "password_reset"
|
PurposePasswordReset = "password_reset"
|
||||||
PurposeEmailVerification = "email_verification"
|
PurposeEmailVerification = "email_verification"
|
||||||
|
// PurposeLoginOTP is a 6-digit one-time code emailed to a user whose
|
||||||
|
// account has been unverified for >30 days. Verification happens
|
||||||
|
// per-user (FindValidForUser), so the 1M-keyspace can't be brute-forced
|
||||||
|
// offline by hash collision — only online against rate limits.
|
||||||
|
PurposeLoginOTP = "login_otp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OAuthAccount represents a row in the `oauth_accounts` table — one entry
|
// OAuthAccount represents a row in the `oauth_accounts` table — one entry
|
||||||
|
|||||||
@@ -44,6 +44,31 @@ func (r *OAuthAccountRepo) FindByProvider(ctx context.Context, provider, provide
|
|||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListProvidersForUser returns the distinct provider names linked to this
|
||||||
|
// user, ordered alphabetically. Used by the settings UI to mark each
|
||||||
|
// social-connect row as "Connected" vs "Connect".
|
||||||
|
func (r *OAuthAccountRepo) ListProvidersForUser(ctx context.Context, userID string) ([]string, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT DISTINCT provider
|
||||||
|
FROM oauth_accounts
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY provider`
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, q, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list oauth providers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p string
|
||||||
|
if err := rows.Scan(&p); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan oauth provider: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Link inserts a new oauth_accounts row tying a provider identity to an
|
// Link inserts a new oauth_accounts row tying a provider identity to an
|
||||||
// existing user. Idempotent: a second call for the same (provider, sub)
|
// existing user. Idempotent: a second call for the same (provider, sub)
|
||||||
// returns the existing row instead of erroring on the unique constraint.
|
// returns the existing row instead of erroring on the unique constraint.
|
||||||
|
|||||||
@@ -380,6 +380,32 @@ func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindValidForUser returns a non-used, non-expired token by user + hash +
|
||||||
|
// purpose. Used by the OTP login flow where the keyspace is small (6
|
||||||
|
// digits) — scoping by user_id prevents a global hash-collision lookup.
|
||||||
|
func (r *AuthTokenRepo) FindValidForUser(ctx context.Context, userID, tokenHash, purpose string) (*model.AuthToken, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
|
||||||
|
FROM auth_tokens
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND token_hash = $2
|
||||||
|
AND purpose = $3
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at > now()
|
||||||
|
LIMIT 1`
|
||||||
|
|
||||||
|
t := &model.AuthToken{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, userID, tokenHash, purpose).
|
||||||
|
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find token: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Consume marks a token as used. After this call the token can never be
|
// Consume marks a token as used. After this call the token can never be
|
||||||
// honoured again, even if it has not yet expired.
|
// honoured again, even if it has not yet expired.
|
||||||
func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error {
|
func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error {
|
||||||
|
|||||||
@@ -503,6 +503,7 @@ func (s *Server) setupRouter() {
|
|||||||
}
|
}
|
||||||
r.Post("/signup", s.authH.SignUp)
|
r.Post("/signup", s.authH.SignUp)
|
||||||
r.Post("/login", s.authH.LogIn)
|
r.Post("/login", s.authH.LogIn)
|
||||||
|
r.Post("/verify-login-otp", s.authH.VerifyLoginOTP)
|
||||||
r.Post("/logout", s.authH.LogOut)
|
r.Post("/logout", s.authH.LogOut)
|
||||||
r.Post("/forgot-password", s.authH.ForgotPassword)
|
r.Post("/forgot-password", s.authH.ForgotPassword)
|
||||||
r.Post("/reset-password", s.authH.ResetPassword)
|
r.Post("/reset-password", s.authH.ResetPassword)
|
||||||
@@ -520,9 +521,18 @@ func (s *Server) setupRouter() {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
r.Get("/me", s.authH.Me)
|
r.Get("/me", s.authH.Me)
|
||||||
|
r.Get("/linked-providers", s.authH.LinkedProviders)
|
||||||
r.Post("/email", s.authH.SetEmail)
|
r.Post("/email", s.authH.SetEmail)
|
||||||
r.Post("/change-password", s.authH.ChangePassword)
|
r.Post("/change-password", s.authH.ChangePassword)
|
||||||
r.Post("/resend-verification", s.authH.ResendVerification)
|
r.Post("/resend-verification", s.authH.ResendVerification)
|
||||||
|
// Link-provider flow — requires the caller to be signed
|
||||||
|
// in; the state encodes their id so the callback links to
|
||||||
|
// THIS user instead of running the normal sign-in path.
|
||||||
|
// The callback itself is the shared /oauth/{provider}/callback
|
||||||
|
// above — the state's link_user_id field switches behaviour.
|
||||||
|
if s.oauthH != nil {
|
||||||
|
r.Get("/oauth/{provider}/link/start", s.oauthH.LinkStart)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -129,10 +130,32 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string)
|
|||||||
return s.issueSession(u)
|
return s.issueSession(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoginResult is the two-state outcome of LogIn:
|
||||||
|
//
|
||||||
|
// - `Auth != nil` — credentials verified AND email is fine. Caller
|
||||||
|
// sets the session cookie and returns the user.
|
||||||
|
// - `OTPRequired == true` — credentials verified but the account has been
|
||||||
|
// unverified for more than 30 days. An OTP has been emailed; the caller
|
||||||
|
// must collect it from the user and call VerifyLoginOTP to complete the
|
||||||
|
// sign-in. No session cookie is set yet.
|
||||||
|
//
|
||||||
|
// One of the two is always set on a non-error return.
|
||||||
|
type LoginResult struct {
|
||||||
|
Auth *AuthResult
|
||||||
|
OTPRequired bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// staleUnverifiedAge is how long an account can stay unverified before
|
||||||
|
// every subsequent email/password login is gated on an OTP. Anything
|
||||||
|
// shorter and a normal user who simply hasn't checked their inbox yet
|
||||||
|
// would hit the gate; longer and abandoned-but-not-verified accounts
|
||||||
|
// remain a forever-credential. 30 days is the documented requirement.
|
||||||
|
const staleUnverifiedAge = 30 * 24 * time.Hour
|
||||||
|
|
||||||
// LogIn authenticates a user by email + password. The error returned for an
|
// LogIn authenticates a user by email + password. The error returned for an
|
||||||
// unknown email is intentionally identical to a wrong password — never leak
|
// unknown email is intentionally identical to a wrong password — never leak
|
||||||
// which one was wrong.
|
// which one was wrong. May return early with OTPRequired=true; see LoginResult.
|
||||||
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthResult, error) {
|
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*LoginResult, error) {
|
||||||
u, err := s.users.FindByEmail(ctx, email)
|
u, err := s.users.FindByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, repository.ErrUserNotFound) {
|
if errors.Is(err, repository.ErrUserNotFound) {
|
||||||
@@ -148,7 +171,97 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthR
|
|||||||
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
return s.issueSession(u)
|
|
||||||
|
// OTP gate — unverified email AND account is older than the grace
|
||||||
|
// window. Mint an OTP and signal the caller to collect it. We do NOT
|
||||||
|
// issue a session here.
|
||||||
|
if u.EmailVerifiedAt == nil && time.Since(u.CreatedAt) > staleUnverifiedAge {
|
||||||
|
if u.Email == nil || *u.Email == "" {
|
||||||
|
// No email to send to → fall through to "invalid credentials"
|
||||||
|
// rather than expose this corner case.
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if err := s.sendLoginOTP(ctx, u); err != nil {
|
||||||
|
s.logger.Warn("login otp send failed",
|
||||||
|
zap.String("user", u.ID), zap.Error(err))
|
||||||
|
return nil, fmt.Errorf("send login otp: %w", err)
|
||||||
|
}
|
||||||
|
return &LoginResult{OTPRequired: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.issueSession(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &LoginResult{Auth: res}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyLoginOTP completes a login that was paused by the stale-unverified
|
||||||
|
// OTP gate. Re-checks the password (so an attacker who steals an OTP email
|
||||||
|
// still can't sign in without the password), validates the OTP, flips
|
||||||
|
// email_verified_at to now(), and issues a fresh session.
|
||||||
|
func (s *AuthService) VerifyLoginOTP(ctx context.Context, email, password, otp string) (*AuthResult, error) {
|
||||||
|
u, err := s.users.FindByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if u.PasswordHash == nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
otpHash := hashToken(otp)
|
||||||
|
tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidOrExpiredLink
|
||||||
|
}
|
||||||
|
if err := s.tokens.Consume(ctx, tok.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u2, err := s.users.FindByID(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.issueSession(u2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendLoginOTP mints a fresh 6-digit code, invalidates any older pending
|
||||||
|
// codes, stores the SHA-256 hash, and emails the raw code to the user.
|
||||||
|
func (s *AuthService) sendLoginOTP(ctx context.Context, u *model.User) error {
|
||||||
|
if u.Email == nil || *u.Email == "" {
|
||||||
|
return errors.New("no email on file")
|
||||||
|
}
|
||||||
|
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeLoginOTP)
|
||||||
|
|
||||||
|
otp, hash, err := newOTP()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.tokens.Create(ctx, u.ID, model.PurposeLoginOTP, hash, 10*time.Minute); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := loginOTPEmail(u.Name, otp)
|
||||||
|
return s.mail.Send(*u.Email, "Your login code — Prabhupāda Says", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOTP returns a freshly-generated 6-digit code + its SHA-256 hex hash.
|
||||||
|
// Modulo bias on a uniform 32-bit source is negligible for the 1M keyspace
|
||||||
|
// (10⁶ / 2³² ≈ 4 parts in 10⁴) and irrelevant against rate-limited online
|
||||||
|
// guessing, which is the only attack model we care about for this size.
|
||||||
|
func newOTP() (raw, hash string, err error) {
|
||||||
|
var b [4]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
n := binary.BigEndian.Uint32(b[:]) % 1_000_000
|
||||||
|
raw = fmt.Sprintf("%06d", n)
|
||||||
|
hash = hashToken(raw)
|
||||||
|
return raw, hash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthProfile is the provider-agnostic profile shape consumed by
|
// OAuthProfile is the provider-agnostic profile shape consumed by
|
||||||
@@ -168,6 +281,49 @@ type OAuthProfile struct {
|
|||||||
// startup). Handler maps to 503.
|
// startup). Handler maps to 503.
|
||||||
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
|
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
|
||||||
|
|
||||||
|
// ErrOAuthAccountClaimed is returned by LinkOAuthToCurrentUser when the
|
||||||
|
// provider identity is already linked to a different user. We refuse to
|
||||||
|
// move the linkage — the rightful owner of the OAuth account at the
|
||||||
|
// provider can sign in via the normal flow.
|
||||||
|
var ErrOAuthAccountClaimed = errors.New("oauth account already linked to another user")
|
||||||
|
|
||||||
|
// LinkOAuthToCurrentUser ties an OAuth provider identity to a specific
|
||||||
|
// already-authenticated user (settings → Connect flow). Refuses to move
|
||||||
|
// the linkage if some other user already claims it.
|
||||||
|
//
|
||||||
|
// Email policy: the user's primary email is NEVER touched by this call.
|
||||||
|
// The provider's email is stored on the oauth_accounts row only for
|
||||||
|
// reference (display in the future, audit). If the user wants to change
|
||||||
|
// their primary email they have to do it explicitly via SetEmail.
|
||||||
|
func (s *AuthService) LinkOAuthToCurrentUser(ctx context.Context, userID string, prof OAuthProfile) error {
|
||||||
|
if s.oauth == nil {
|
||||||
|
return ErrOAuthUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this (provider, sub) is already linked, the only valid case is
|
||||||
|
// that it's linked to the SAME user we're trying to link to. Any other
|
||||||
|
// user owning this linkage means the OAuth account was already claimed
|
||||||
|
// (typically the user mis-clicked or it's a different person on a
|
||||||
|
// shared device) — refuse rather than silently steal it.
|
||||||
|
if existing, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
|
||||||
|
if existing.UserID != userID {
|
||||||
|
return ErrOAuthAccountClaimed
|
||||||
|
}
|
||||||
|
// Same user — idempotent: refresh the denormalised profile fields
|
||||||
|
// and return success.
|
||||||
|
_, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||||
|
return err
|
||||||
|
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh link.
|
||||||
|
_, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
|
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
|
||||||
//
|
//
|
||||||
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
|
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
|
||||||
@@ -312,6 +468,18 @@ func optStr(s string) *string {
|
|||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinkedProviders returns the OAuth provider names already linked to the
|
||||||
|
// caller's account ("google", "facebook"). Used by the settings UI to flip
|
||||||
|
// the per-provider rows between "Connect" and "Connected".
|
||||||
|
func (s *AuthService) LinkedProviders(ctx context.Context, userID string) ([]string, error) {
|
||||||
|
if s.oauth == nil {
|
||||||
|
// OAuth repo not wired (no Postgres). No-op rather than erroring —
|
||||||
|
// settings just shows everything as un-connected.
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return s.oauth.ListProvidersForUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// ChangePassword sets the caller's password, in two modes:
|
// ChangePassword sets the caller's password, in two modes:
|
||||||
//
|
//
|
||||||
// 1. Account HAS a password — caller must supply the current one. We
|
// 1. Account HAS a password — caller must supply the current one. We
|
||||||
@@ -616,6 +784,15 @@ clicking the link below:</p>
|
|||||||
shown at the top of the site after you log in.</p>`, htmlEscape(name), link, link)
|
shown at the top of the site after you log in.</p>`, htmlEscape(name), link, link)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loginOTPEmail(name, otp string) string {
|
||||||
|
return fmt.Sprintf(`<p>Hari Bol %s,</p>
|
||||||
|
<p>Your account on <strong>Prabhupāda Says</strong> has been unverified for a
|
||||||
|
while, so we need a quick one-time code before signing you in:</p>
|
||||||
|
<p style="font-size:1.5rem;font-weight:600;letter-spacing:0.25em;margin:1.25rem 0;">%s</p>
|
||||||
|
<p>This code expires in 10 minutes. If you didn't try to sign in, you can
|
||||||
|
safely ignore this email.</p>`, htmlEscape(name), otp)
|
||||||
|
}
|
||||||
|
|
||||||
func htmlEscape(s string) string {
|
func htmlEscape(s string) string {
|
||||||
r := strings.NewReplacer(
|
r := strings.NewReplacer(
|
||||||
"&", "&",
|
"&", "&",
|
||||||
|
|||||||
+28
-16
@@ -19,11 +19,16 @@ import (
|
|||||||
// `Next` is the post-login redirect path on the frontend. We sign it (along
|
// `Next` is the post-login redirect path on the frontend. We sign it (along
|
||||||
// with Nonce + IssuedAt) under HMAC so a tampered ?next= can't redirect the
|
// with Nonce + IssuedAt) under HMAC so a tampered ?next= can't redirect the
|
||||||
// user to an arbitrary URL after sign-in.
|
// user to an arbitrary URL after sign-in.
|
||||||
|
//
|
||||||
|
// `LinkUserID` is set only by the /link/start endpoint — it pins the OAuth
|
||||||
|
// callback to the user who initiated the link. An empty value means the
|
||||||
|
// callback should run the normal sign-in / sign-up flow instead.
|
||||||
type statePayload struct {
|
type statePayload struct {
|
||||||
Nonce string `json:"n"` // 16 random bytes, base64-urlencoded
|
Nonce string `json:"n"` // 16 random bytes, base64-urlencoded
|
||||||
Next string `json:"u"` // safe path, validated by handler
|
Next string `json:"u"` // safe path, validated by handler
|
||||||
IssuedAt int64 `json:"t"` // unix seconds
|
IssuedAt int64 `json:"t"` // unix seconds
|
||||||
Provider string `json:"p"` // "google" | "facebook"
|
Provider string `json:"p"` // "google" | "facebook"
|
||||||
|
LinkUserID string `json:"l,omitempty"` // user id to link the provider to, empty for sign-in flow
|
||||||
}
|
}
|
||||||
|
|
||||||
// stateMaxAge bounds how long a /start may be in flight. Ten minutes is
|
// stateMaxAge bounds how long a /start may be in flight. Ten minutes is
|
||||||
@@ -34,16 +39,20 @@ const stateMaxAge = 10 * time.Minute
|
|||||||
// EncodeState signs and packs a fresh state value bound to the given provider
|
// EncodeState signs and packs a fresh state value bound to the given provider
|
||||||
// and post-login redirect path. The returned string is what goes into both
|
// and post-login redirect path. The returned string is what goes into both
|
||||||
// the `state` query param and the cookie.
|
// the `state` query param and the cookie.
|
||||||
func EncodeState(secret []byte, provider, next string) (string, error) {
|
//
|
||||||
|
// `linkUserID` pins the callback to a specific user (link-to-current-user
|
||||||
|
// flow). Pass "" for the normal sign-in flow.
|
||||||
|
func EncodeState(secret []byte, provider, next, linkUserID string) (string, error) {
|
||||||
nonceB := make([]byte, 16)
|
nonceB := make([]byte, 16)
|
||||||
if _, err := rand.Read(nonceB); err != nil {
|
if _, err := rand.Read(nonceB); err != nil {
|
||||||
return "", fmt.Errorf("state nonce: %w", err)
|
return "", fmt.Errorf("state nonce: %w", err)
|
||||||
}
|
}
|
||||||
p := statePayload{
|
p := statePayload{
|
||||||
Nonce: base64.RawURLEncoding.EncodeToString(nonceB),
|
Nonce: base64.RawURLEncoding.EncodeToString(nonceB),
|
||||||
Next: next,
|
Next: next,
|
||||||
IssuedAt: time.Now().Unix(),
|
IssuedAt: time.Now().Unix(),
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
|
LinkUserID: linkUserID,
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(p)
|
body, err := json.Marshal(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -59,30 +68,33 @@ func EncodeState(secret []byte, provider, next string) (string, error) {
|
|||||||
// DecodeState verifies the signature, checks the freshness window, and
|
// DecodeState verifies the signature, checks the freshness window, and
|
||||||
// returns the embedded payload. Any failure surfaces as ErrInvalidState so
|
// returns the embedded payload. Any failure surfaces as ErrInvalidState so
|
||||||
// the handler maps it to a single error path.
|
// the handler maps it to a single error path.
|
||||||
func DecodeState(secret []byte, raw string) (provider, next string, err error) {
|
//
|
||||||
|
// `linkUserID` is empty for sign-in flows and set for link-to-current-user
|
||||||
|
// flows — callers should branch on it.
|
||||||
|
func DecodeState(secret []byte, raw string) (provider, next, linkUserID string, err error) {
|
||||||
parts := strings.SplitN(raw, ".", 2)
|
parts := strings.SplitN(raw, ".", 2)
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return "", "", ErrInvalidState
|
return "", "", "", ErrInvalidState
|
||||||
}
|
}
|
||||||
bodyEnc, sigEnc := parts[0], parts[1]
|
bodyEnc, sigEnc := parts[0], parts[1]
|
||||||
mac := hmac.New(sha256.New, secret)
|
mac := hmac.New(sha256.New, secret)
|
||||||
mac.Write([]byte(bodyEnc))
|
mac.Write([]byte(bodyEnc))
|
||||||
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
if !hmac.Equal([]byte(expected), []byte(sigEnc)) {
|
if !hmac.Equal([]byte(expected), []byte(sigEnc)) {
|
||||||
return "", "", ErrInvalidState
|
return "", "", "", ErrInvalidState
|
||||||
}
|
}
|
||||||
body, err := base64.RawURLEncoding.DecodeString(bodyEnc)
|
body, err := base64.RawURLEncoding.DecodeString(bodyEnc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", ErrInvalidState
|
return "", "", "", ErrInvalidState
|
||||||
}
|
}
|
||||||
var p statePayload
|
var p statePayload
|
||||||
if err := json.Unmarshal(body, &p); err != nil {
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
return "", "", ErrInvalidState
|
return "", "", "", ErrInvalidState
|
||||||
}
|
}
|
||||||
if time.Since(time.Unix(p.IssuedAt, 0)) > stateMaxAge {
|
if time.Since(time.Unix(p.IssuedAt, 0)) > stateMaxAge {
|
||||||
return "", "", ErrInvalidState
|
return "", "", "", ErrInvalidState
|
||||||
}
|
}
|
||||||
return p.Provider, p.Next, nil
|
return p.Provider, p.Next, p.LinkUserID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrInvalidState is returned whenever signature or freshness checks fail.
|
// ErrInvalidState is returned whenever signature or freshness checks fail.
|
||||||
|
|||||||
Reference in New Issue
Block a user