k_card/component3/phone.go

128 lines
3.6 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
)
// PhoneClient acquires and caches a session token from Component 2 on the phone.
// The token represents a completed FIDO2 assertion (user fingerprint on card).
// Component 3 includes this token in Authorization headers when calling endpoints
// directly, so the server can verify that a valid card session was established.
type PhoneClient struct {
baseURL string
username string
mu sync.Mutex
token string
expiresAt time.Time
}
func NewPhoneClient(baseURL, username string) *PhoneClient {
return &PhoneClient{baseURL: baseURL, username: username}
}
// EnsureSession returns a valid session token. It first tries /auth/get-token
// (no card interaction if the phone already has an active session), then falls
// back to /session/login (triggers FIDO2 assertion on the card).
func (c *PhoneClient) EnsureSession() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && time.Now().Before(c.expiresAt) {
return c.token, nil
}
if tok, expiresIn, err := c.getToken(); err == nil {
ttl := time.Duration(expiresIn) * time.Second
if ttl <= 0 {
ttl = 5 * time.Minute
}
c.token = tok
c.expiresAt = time.Now().Add(ttl - 30*time.Second)
return tok, nil
}
log.Printf("phone: logging in as %q (FIDO2 card interaction required)", c.username)
return c.login()
}
// Invalidate clears the cached token, forcing a fresh login on the next call.
func (c *PhoneClient) Invalidate() {
c.mu.Lock()
c.token = ""
c.mu.Unlock()
}
// getToken calls /auth/get-token on the phone and returns the token if the phone
// already has an active session. Avoids card interaction. Caller must hold c.mu.
func (c *PhoneClient) getToken() (string, int, error) {
resp, err := http.Post(c.baseURL+"/auth/get-token", "application/json", bytes.NewReader([]byte("{}")))
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var result struct {
Ok bool `json:"ok"`
Token string `json:"token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", 0, fmt.Errorf("parse token response: %w", err)
}
if !result.Ok {
return "", 0, fmt.Errorf("no active session: %s", result.Error)
}
return result.Token, result.ExpiresIn, nil
}
// login posts to /session/login on the phone and stores the returned token.
// Caller must hold c.mu.
func (c *PhoneClient) login() (string, error) {
body, _ := json.Marshal(map[string]string{"username": c.username})
resp, err := http.Post(c.baseURL+"/session/login", "application/json", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("phone unreachable (%s): %w", c.baseURL, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var result struct {
Ok bool `json:"ok"`
SessionToken string `json:"session_token"`
TtlSeconds int `json:"ttl_seconds"`
Error string `json:"error"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", fmt.Errorf("parse phone response: %w (body: %s)", err, raw)
}
if !result.Ok {
return "", fmt.Errorf("login rejected: %s", result.Error)
}
if result.SessionToken == "" {
return "", fmt.Errorf("phone returned empty session token")
}
ttl := time.Duration(result.TtlSeconds) * time.Second
if ttl <= 0 {
ttl = 5 * time.Minute
}
// Expire the cached token 30 s early to avoid racing the server-side expiry.
c.token = result.SessionToken
c.expiresAt = time.Now().Add(ttl - 30*time.Second)
log.Printf("phone: session acquired (expires in %v)", ttl)
return c.token, nil
}