feat: account connect
This commit is contained in:
@@ -53,6 +53,12 @@ type verifyReq struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type verifyLoginOTPReq struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
OTP string `json:"otp"`
|
||||
}
|
||||
|
||||
type setEmailReq struct {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var req logInReq
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// currently-logged-in user.
|
||||
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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/pkg/auth"
|
||||
"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"))
|
||||
|
||||
state, err := oauth.EncodeState(h.stateSecret, providerName, 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)
|
||||
@@ -134,7 +135,7 @@ func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||
return
|
||||
@@ -161,6 +162,14 @@ func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
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{
|
||||
Provider: prof.Provider,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (h *OAuthHandler) callbackURL(provider string) string {
|
||||
return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback"
|
||||
|
||||
Reference in New Issue
Block a user