feat(api): /devices register + paseto v4 (middleware Bearer, bootstrap mobile du token)

This commit is contained in:
m
2026-09-10 07:25:11 +02:00
parent bdf1fadce9
commit caa6e6483c
18 changed files with 407 additions and 27 deletions
+105
View File
@@ -0,0 +1,105 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/auth"
)
func newTestRouterForAuth() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/devices", DevicesRegister)
grp := r.Group("")
grp.Use(RequireDevice)
grp.GET("/files", FilesList)
return r
}
func TestDevicesRegisterValid(t *testing.T) {
m, _ := auth.NewManager("test-secret")
Auth = m
defer func() { Auth = nil }()
deviceID := "0123456789abcdef0123456789abcdef"
body := `{"deviceId":"` + deviceID + `"}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/devices", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
newTestRouterForAuth().ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var envelope struct {
Data struct {
DeviceID string `json:"deviceId"`
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if envelope.Data.DeviceID != deviceID {
t.Errorf("deviceId = %q", envelope.Data.DeviceID)
}
verified, err := m.Verify(envelope.Data.Token)
if err != nil || verified != deviceID {
t.Errorf("token invalid: %v", err)
}
}
func TestDevicesRegisterRejectsBadDeviceID(t *testing.T) {
Auth, _ = auth.NewManager("test-secret")
defer func() { Auth = nil }()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/devices", strings.NewReader(`{"deviceId":"UPPERCASEANDTOOLONG"}`))
req.Header.Set("Content-Type", "application/json")
newTestRouterForAuth().ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("status = %d", rec.Code)
}
}
func TestRequireDeviceRejectsMissingToken(t *testing.T) {
Auth, _ = auth.NewManager("test-secret")
defer func() { Auth = nil }()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/files", nil)
newTestRouterForAuth().ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
}
func TestRequireDeviceAcceptsValidToken(t *testing.T) {
Auth, _ = auth.NewManager("test-secret")
defer func() { Auth = nil }()
deviceID := "0123456789abcdef0123456789abcdef"
signed, err := Auth.Issue(deviceID)
if err != nil {
t.Fatalf("Issue: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/files", nil)
req.Header.Set("Authorization", "Bearer "+signed)
newTestRouterForAuth().ServeHTTP(rec, req)
// FilesList is still a 501 stub — the point is it got past the middleware.
if rec.Code != 501 {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
}
+29 -1
View File
@@ -1,8 +1,36 @@
package handlers
import (
"regexp"
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api"
)
func DevicesRegister(c *gin.Context) { api.NotImplemented(c) }
var deviceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
type DeviceRegisterRequest struct {
DeviceID string `json:"deviceId"`
}
func DevicesRegister(c *gin.Context) {
var req DeviceRegisterRequest
if err := c.ShouldBindJSON(&req); err != nil || req.DeviceID == "" {
api.Error(c, 400, "INVALID_REQUEST", "missing deviceId")
return
}
if !deviceIDPattern.MatchString(req.DeviceID) {
api.Error(c, 400, "INVALID_DEVICE_ID", "deviceId must be 32 lowercase hex chars")
return
}
token, err := Auth.Issue(req.DeviceID)
if err != nil {
api.Error(c, 500, "TOKEN_ERROR", "could not issue token")
return
}
api.OK(c, gin.H{"deviceId": req.DeviceID, "token": token})
}
+39
View File
@@ -0,0 +1,39 @@
package handlers
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api"
"github.com/vaultdrop/backend/pkg/auth"
)
const DeviceIDKey = "device_id"
// Auth issues/verifies device bearer tokens; set once at startup (cmd/server).
var Auth *auth.Manager
// RequireDevice authenticates the bearer paseto token and stores the resolved
// device_id in the gin context (cf. docs/api-v1.md §2).
func RequireDevice(c *gin.Context) {
header := c.GetHeader("Authorization")
token, found := strings.CutPrefix(header, "Bearer ")
if Auth == nil || !found || token == "" {
api.Error(c, 401, "UNAUTHORIZED", "missing bearer token")
c.Abort()
return
}
deviceID, err := Auth.Verify(token)
if err != nil {
api.Error(c, 401, "UNAUTHORIZED", "invalid or expired token")
c.Abort()
return
}
c.Set(DeviceIDKey, deviceID)
c.Next()
}