refactor: move SSH auth and initial data loading to splash screen

previously main.go handled auth directly, it connected to the database,
ran migrations, created a JWT manager and SSHAuthService, and
authenticated the SSH key before the TUI even started. this commit rips
all of that out

Auth now happens inside the TUI during the splash screen via
splashAuthCmd, which calls the /auth/token endpoint. if no user exists
or the fingerprint it auto-creates one using the SSH public ke, so there
is no longer a separate registartion flow. The token and user com back
in the response.

once authed splashViewInitCmd fires a single /view/init request that
loads everything needed to start the app, products, cart, addresses,
cards, and orders, before switching to the shop page

on the model side, User/*models.user and gossh.PublicKey were replaced
with plain Fingerprint and SSHPublicKeyStr strings, and NewModelWithAuth
was updated too
This commit is contained in:
2026-03-27 11:34:34 +01:00
parent 4eeb3eda9a
commit 296c052fa7
8 changed files with 229 additions and 107 deletions

View File

@@ -8,6 +8,8 @@ import (
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"gorm.io/gorm"
)
// AuthHandler handles authentication-related requests
@@ -27,14 +29,16 @@ func NewAuthHandler(jwtManager *auth.JWTManager, authFingerprintKey string) *Aut
// Type to represent a request to exchange SSH fingerprint for JWT
type TokenRequest struct {
Fingerprint string `json:"fingerprint"`
SSHPublicKey string `json:"ssh_public_key"`
ClientSecret string `json:"client_secret"`
}
// Type to represent the JWT token response
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
User models.PublicUser `json:"user"`
}
func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
@@ -60,8 +64,22 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
// look up user by his fingies
var user models.User
if err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error; err != nil {
utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "no user found for this fingerprint", nil)
err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error
if err == gorm.ErrRecordNotFound {
if req.SSHPublicKey == "" {
utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "no user found for this fingerprint", nil)
return
}
user = models.User{
SSHKeyFingerprint: req.Fingerprint,
SSHPublicKey: req.SSHPublicKey,
}
if err := db.Create(&user).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create user", nil)
return
}
} else if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "database error", nil)
return
}
@@ -75,6 +93,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
"access_token": token,
"token_type": "Bearer",
"expires_in": 1800, // 30 minutes in seconds
"user": user.ToPublic(),
})
}

51
api/handlers/view.go Normal file
View File

@@ -0,0 +1,51 @@
package handlers
import (
"net/http"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
type ViewHandler struct{}
func NewViewHandler() *ViewHandler {
return &ViewHandler{}
}
func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
userID := middleware.UserIDFromContext(r.Context())
var user models.User
if err := db.First(&user, userID).Error; err != nil {
utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "user not found", nil)
return
}
var products []models.Coffee
db.Find(&products)
var cartItems []models.CartItem
db.Where("user_id = ?", userID).Preload("Coffee").Find(&cartItems)
var addresses []models.Address
db.Where("user_id = ?", userID).Find(&addresses)
var cards []models.Card
db.Where("user_id = ?", userID).Find(&cards)
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"user": user.ToPublic(),
"products": products,
"cart": cartItems,
"addresses": addresses,
"cards": cards,
"orders": orders,
})
}

View File

@@ -26,6 +26,7 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan
cardHandler := handlers.NewCardHandler(stripeSecretKey)
orderHandler := handlers.NewOrderHandler()
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
viewHandler := handlers.NewViewHandler()
// API v1 routes
r.Route("/api/v1", func(r chi.Router) {
@@ -46,6 +47,8 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth)
r.Get("/view/init", viewHandler.GetViewInit)
// Cart
r.Route("/cart", func(r chi.Router) {
r.Get("/", cartHandler.GetCart)

45
main.go
View File

@@ -7,6 +7,7 @@ import (
"os"
"os/signal"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -17,8 +18,6 @@ import (
"terminalShop/pkg/auth"
"terminalShop/pkg/config"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/tui"
)
@@ -28,7 +27,6 @@ const (
)
var (
authService *auth.SSHAuthService
apiURL string
authFingerprintKey string
)
@@ -37,28 +35,14 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
// Extract SSH public key from session
pubKey := s.PublicKey()
var user *models.User
var token string
var fingerprint, pubKeyStr string
if pubKey != nil {
// Authenticate with SSH key (auto-creates user on first connect!)
authenticatedUser, jwtToken, err := authService.AuthenticateSSHKey(pubKey)
if err != nil {
log.Printf("Auth error: %v", err)
// Continue as guest
} else {
user = authenticatedUser
token = jwtToken
displayName := user.SSHKeyFingerprint[:16]
if user.Name != "" {
displayName = user.Name
}
log.Printf("User authenticated: %s (ID: %d)", displayName, user.ID)
}
fingerprint = auth.GetSSHKeyFingerprint(pubKey)
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
}
// Create TUI model with user context (no registration screen needed!)
m := tui.NewModelWithAuth(user, false, s.PublicKey(), token, apiURL, authFingerprintKey)
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}
@@ -69,25 +53,6 @@ func main() {
log.Fatalf("Failed to load config: %v", err)
}
// Initialize database connection (for user management)
// Use same database as API server
db, err := database.Connect(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer database.Close()
// Run migrations (includes users table)
if err := database.Migrate(db); err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}
// Initialize JWT manager (uses same secret as API)
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
// Initialize SSH auth service
authService = auth.NewSSHAuthService(db, jwtManager)
// Set API URL and auth key from config for TUI connections
apiURL = cfg.APIURL
authFingerprintKey = cfg.AuthFingerprintKey

View File

@@ -39,6 +39,7 @@ func (c *Client) doRequest(req *http.Request) (*http.Response, error) {
// TokenRequest represents a request to exchange SSH fingerprint for JWT.
type TokenRequest struct {
Fingerprint string `json:"fingerprint"`
SSHPublicKey string `json:"ssh_public_key"`
ClientSecret string `json:"client_secret"`
}
@@ -46,16 +47,16 @@ type TokenRequest struct {
type TokenResponse struct {
Success bool `json:"success"`
Data struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
User models.PublicUser `json:"user"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
// RefreshToken exchanges an SSH fingerprint and client secret for a new JWT.
// It does not update c.Token — the caller is responsible for that.
func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error) {
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
reqBody := TokenRequest{
@@ -65,35 +66,40 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal token request: %w", err)
return "", models.PublicUser{}, fmt.Errorf("failed to marshal token request: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create token request: %w", err)
return "", models.PublicUser{}, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("token refresh request failed: %w", err)
return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token refresh returned status %d", resp.StatusCode)
return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode)
}
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("failed to decode token response: %w", err)
return "", models.PublicUser{}, fmt.Errorf("failed to decode token response: %w", err)
}
if !tokenResp.Success || tokenResp.Data.AccessToken == "" {
return "", fmt.Errorf("token refresh failed: no access token in response")
return "", models.PublicUser{}, fmt.Errorf("token refresh failed: no access token in response")
}
return tokenResp.Data.AccessToken, nil
return tokenResp.Data.AccessToken, tokenResp.Data.User, nil
}
func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error) {
token, _, err := c.GetOrCreateToken(fingerprint, "", clientSecret)
return token, err
}
// APIResponse represents the standard API response format
@@ -729,3 +735,37 @@ func (c *Client) DeleteAddress(id uint) error {
return nil
}
type ViewInitData struct {
User models.PublicUser `json:"user"`
Products []models.Coffee `json:"products"`
Cart []CartItemData `json:"cart"`
Addresses []models.Address `json:"addresses"`
Cards []models.Card `json:"cards"`
Orders []models.Order `json:"orders"`
}
type ViewInitResponse struct {
Success bool `json:"success"`
Data ViewInitData `json:"data"`
Error *APIError `json:"error"`
}
// GetViewInit fetches all initial data in one request
func (c *Client) GetViewInit() (ViewInitData, error) {
url := fmt.Sprintf("%s/api/v1/view/init", c.BaseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return ViewInitData{}, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return ViewInitData{}, fmt.Errorf("view init request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode)
}
var viewResp ViewInitResponse
return viewResp.Data, nil
}

View File

@@ -11,10 +11,8 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea"
gossh "golang.org/x/crypto/ssh"
"terminalShop/pkg/api"
"terminalShop/pkg/auth"
"terminalShop/pkg/models"
)
@@ -53,12 +51,11 @@ const resizeDebounce = 50 * time.Millisecond
type Model struct {
// User authentication
User *models.User // Authenticated user (nil if not logged in)
IsNewUser bool // True if user needs to register
SSHPublicKey gossh.PublicKey // SSH public key for registration
User *models.User
Fingerprint string
SSHPublicKeyStr string
AccessToken string
AuthFingerprintKey string // shared secret for /auth/token refresh
UsernameInput string // Input for username during registration
// Shop state
Username string
@@ -158,7 +155,6 @@ func (m Model) checkoutStep() int {
func (m *Model) updateLayout(width, height int) {
m.viewportWidth = width
m.viewportHeight = height
switch {
case width < 20 || height < 10:
m.size = undersized
@@ -222,21 +218,15 @@ func (m Model) fetchProductsCmd() tea.Msg {
}
func (m Model) Init() tea.Cmd {
// Fetch products and cart from API on startup, and schedule token refresh
cmds := []tea.Cmd {
m.fetchProductsCmd,
m.fetchCartCmd,
return tea.Batch(
m.splashAuthCmd,
tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg {
return DelayCompleteMsg{}
}),
tea.Tick(700*time.Microsecond, func(t time.Time) tea.Msg {
tea.Tick(700*time.Millisecond, func(t time.Time) tea.Msg {
return splashCursorTickMsg{}
}),
}
if m.User != nil && m.AuthFingerprintKey != "" {
cmds = append(cmds, m.scheduleTokenRefreshCmd())
}
return tea.Batch(cmds...)
)
}
func (m Model) resetPageState() Model {
@@ -301,12 +291,11 @@ func (m Model) scheduleTokenRefreshCmd() tea.Cmd {
// refreshTokenCmd calls the /auth/token endpoint to get a fresh JWT.
func (m Model) refreshTokenCmd() tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil || m.User == nil || m.SSHPublicKey == nil {
if m.APIClient == nil || m.Fingerprint == "" {
return TokenRefreshedMsg{Err: fmt.Errorf("not authenticated, cannot refresh token")}
}
fingerprint := auth.GetSSHKeyFingerprint(m.SSHPublicKey)
token, err := m.APIClient.RefreshToken(fingerprint, m.AuthFingerprintKey)
token, err := m.APIClient.RefreshToken(m.Fingerprint, m.AuthFingerprintKey)
if err != nil {
log.Printf("[TUI] Token refresh failed: %v", err)
return TokenRefreshedMsg{Err: err}
@@ -655,7 +644,7 @@ func (m Model) GetCartItemsSlice() []*models.CartItem {
// NewModel creates a new model with default coffee options
func NewModel(username string) Model {
m := Model{
Username: username,
Username: username,
currentPage: splashPage,
Coffees: []models.Coffee{
{
@@ -729,23 +718,16 @@ func NewModel(username string) Model {
}
// NewModelWithAuth creates a new model with user authentication context
func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey, token string, apiURL string, authFingerprintKey string) Model {
username := "guest"
if user != nil {
username = user.Name
}
m := NewModel(username)
m.User = user
m.IsNewUser = isNewUser
m.SSHPublicKey = pubKey
m.AccessToken = token
m.AuthFingerprintKey = authFingerprintKey
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string) Model {
m := NewModel("")
m.Fingerprint = fingerprint
m.SSHPublicKeyStr = pubKeyStr
m.AuthFingerprintKey = clientSecret
m.currentPage = splashPage
if apiURL != "" {
m.APIClient = api.NewClient(apiURL, token)
m.APIClient = api.NewClient(apiURL, "")
} else {
m.APIClient = api.NewClient("http://localhost:8080", token)
m.APIClient = api.NewClient("http://localhost:8000", "")
}
return m
}

View File

@@ -1,12 +1,49 @@
package tui
import (
"fmt"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type DelayCompleteMsg struct{}
type splashCursorTickMsg struct{}
type SplashAuthMsg struct {
Token string
User models.PublicUser
Err error
}
type ViewInitMsg struct {
Data api.ViewInitData
Err error
}
func (m Model) splashAuthCmd() tea.Msg {
token, user, err := m.APIClient.GetOrCreateToken(
m.Fingerprint,
m.SSHPublicKeyStr,
m.AuthFingerprintKey,
)
if err != nil {
return SplashAuthMsg{Err: fmt.Errorf("auth failed: %w", err)}
}
return SplashAuthMsg{Token: token, User: user}
}
func (m Model) splashViewInitCmd() tea.Msg {
data, err := m.APIClient.GetViewInit()
if err != nil {
return ViewInitMsg{Err: fmt.Errorf("failed to load data: %w", err)}
}
return ViewInitMsg{Data: data}
}
func (m Model) SplashView() string {
accent := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true)
@@ -25,7 +62,7 @@ func (m Model) SplashView() string {
)
return lipgloss.Place(
m.viewportWidth,
m.viewportWidth,
m.viewportHeight,
lipgloss.Center,
lipgloss.Center,

View File

@@ -2,6 +2,8 @@ package tui
import (
"fmt"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -39,24 +41,47 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Tick(resizeDebounce, func(t time.Time) tea.Msg {
return resizeTickMsg{seq: seq}
})
case ProductsMsg:
m.Loading = false
case SplashAuthMsg:
if msg.Err != nil {
m.ErrorMsg = "Failed to load products from the API, using the fallback values"
} else if len(msg.Products) > 0 {
m.Coffees = msg.Products
m.ErrorMsg = ""
m.ErrorMsg = fmt.Sprintf("authentication failed: %v", msg.Err)
return m, nil
}
m.AccessToken = msg.Token
m.APIClient.Token = msg.Token
if msg.User.ID != 0 {
m.Username = msg.User.Name
}
return m, tea.Batch(m.splashViewInitCmd, m.scheduleTokenRefreshCmd())
case ViewInitMsg:
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("failed to load: %v", msg.Err)
m.Loading = false
return m, nil
}
m.Loading = false
if len(msg.Data.Products) > 0 {
m.Coffees = msg.Data.Products
}
if msg.Data.User.ID != 0 {
u := models.User{
ID: msg.Data.User.ID,
Name: msg.Data.User.Email,
Email: msg.Data.User.Email,
}
m.User = &u
m.Username = u.Name
}
cart := &api.CartData{Items: msg.Data.Cart}
m.loadCartFromAPI(cart)
m.SavedAddresses = msg.Data.Addresses
m.SavedCards = msg.Data.Cards
m.Orders = msg.Data.Orders
m.OrdersLoaded = true
m.splashDataReady = true
if m.currentPage == splashPage && m.splashDelayDone {
if m.splashDelayDone {
return m.SwitchPage(shopPage), nil
}
return m, nil
case CartFetchedMsg:
if msg.Err == nil && msg.Cart != nil {
m.loadCartFromAPI(msg.Cart)
}
return m, nil
case CartSyncedMsg:
if msg.Err != nil {
return m, m.fetchCartCmd