162 lines
5.2 KiB
Go
162 lines
5.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"geniusrun/backend/internal/auth"
|
|
"geniusrun/backend/internal/garmin"
|
|
)
|
|
|
|
// handleSetupGarminLogin authenticates against Garmin using an ephemeral,
|
|
// not-yet-persisted session keyed by the OIDC subject -- no users/profile
|
|
// row exists yet at this point (see setupSession in server.go).
|
|
func (s *Server) handleSetupGarminLogin(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
if _, found := userFromContext(r.Context()); found {
|
|
writeError(w, http.StatusConflict, "profile already exists for this account")
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
GarminEmail string `json:"garmin_email"`
|
|
GarminPassword string `json:"garmin_password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if body.GarminEmail == "" || body.GarminPassword == "" {
|
|
writeError(w, http.StatusBadRequest, "garmin_email and garmin_password are required")
|
|
return
|
|
}
|
|
|
|
sess := s.replaceSetupSession(claims.Sub, body.GarminEmail, body.GarminPassword)
|
|
res, err := sess.Client.Authenticate(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
s.recordSetupAuthResult(claims.Sub, res)
|
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
|
}
|
|
|
|
// handleSetupGarminMFA continues an in-progress ephemeral Garmin login
|
|
// (started by handleSetupGarminLogin) with an MFA code, on the same
|
|
// session/subprocess -- never replaces it.
|
|
func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if body.Code == "" {
|
|
writeError(w, http.StatusBadRequest, "code is required")
|
|
return
|
|
}
|
|
|
|
sess, ok := s.setupSessionFor(claims.Sub)
|
|
if !ok {
|
|
writeError(w, http.StatusConflict, "no Garmin connection attempt in progress; connect again")
|
|
return
|
|
}
|
|
res, err := sess.Client.CompleteMFA(r.Context(), body.Code)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
s.recordSetupAuthResult(claims.Sub, res)
|
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
|
}
|
|
|
|
// handleSetupComplete is the single atomic commit point: only reachable
|
|
// once the ephemeral session for this subject last reported
|
|
// garmin.AuthSuccess. Provisions the account, persists the Garmin
|
|
// credentials, marks it connected, and promotes the already-authenticated
|
|
// ephemeral client into the permanent per-user cache instead of discarding
|
|
// it (no redundant re-authentication, no repeat MFA prompt, right after
|
|
// signup).
|
|
func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
if _, found := userFromContext(r.Context()); found {
|
|
writeError(w, http.StatusConflict, "profile already exists for this account")
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if body.DisplayName == "" {
|
|
writeError(w, http.StatusBadRequest, "display_name is required")
|
|
return
|
|
}
|
|
|
|
sess, ok := s.setupSessionFor(claims.Sub)
|
|
if !ok || sess.Status != garmin.AuthSuccess {
|
|
writeError(w, http.StatusConflict, "Garmin isn't connected yet -- connect before completing setup")
|
|
return
|
|
}
|
|
|
|
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
profile, err := s.DB.GetProfile(r.Context(), userID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
profile.GarminEmail = sess.Email
|
|
profile.GarminPassword = sess.Password
|
|
if err := s.DB.UpdateProfile(r.Context(), userID, profile); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if err := s.DB.MarkGarminConnected(r.Context(), userID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
delete(s.setupGarmin, claims.Sub)
|
|
s.userGarmin[userID] = sess.Client
|
|
s.userAuthStatus[userID] = sess.Status
|
|
s.userAuthMessage[userID] = sess.Message
|
|
s.mu.Unlock()
|
|
|
|
if s.GarminBase.TokenStorePath != "" {
|
|
oldDir := setupTokenStoreDir(s.GarminBase.TokenStorePath, claims.Sub)
|
|
newDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
|
|
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
|
|
log.Printf("api: rename setup token store dir for user %d: %v", userID, err)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
|
}
|