feat(api): /devices register + paseto v4 (middleware Bearer, bootstrap mobile du token)
This commit is contained in:
+24
-13
@@ -7,29 +7,34 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/vaultdrop/backend/config"
|
||||
"github.com/vaultdrop/backend/handlers"
|
||||
"github.com/vaultdrop/backend/pkg/auth"
|
||||
)
|
||||
|
||||
func newRouter() *gin.Engine {
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
public := r.Group("/api/v1")
|
||||
{
|
||||
api.GET("/health", handlers.Health)
|
||||
api.POST("/devices", handlers.DevicesRegister)
|
||||
public.GET("/health", handlers.Health)
|
||||
public.POST("/devices", handlers.DevicesRegister)
|
||||
}
|
||||
|
||||
api.GET("/files", handlers.FilesList)
|
||||
api.GET("/files/search", handlers.FilesSearch)
|
||||
api.GET("/files/:id", handlers.FilesGet)
|
||||
api.DELETE("/files/:id", handlers.FilesDelete)
|
||||
api.GET("/files/folders", handlers.FoldersList)
|
||||
api.POST("/files/upload", handlers.FilesUpload)
|
||||
protected := r.Group("/api/v1")
|
||||
protected.Use(handlers.RequireDevice)
|
||||
{
|
||||
protected.GET("/files", handlers.FilesList)
|
||||
protected.GET("/files/search", handlers.FilesSearch)
|
||||
protected.GET("/files/:id", handlers.FilesGet)
|
||||
protected.DELETE("/files/:id", handlers.FilesDelete)
|
||||
protected.GET("/files/folders", handlers.FoldersList)
|
||||
protected.POST("/files/upload", handlers.FilesUpload)
|
||||
|
||||
api.POST("/ocr/jobs", handlers.OcrJobsCreate)
|
||||
api.GET("/ocr/jobs/:id", handlers.OcrJobsGet)
|
||||
protected.POST("/ocr/jobs", handlers.OcrJobsCreate)
|
||||
protected.GET("/ocr/jobs/:id", handlers.OcrJobsGet)
|
||||
|
||||
api.POST("/sync/ops", handlers.SyncOpsPush)
|
||||
api.GET("/sync/permissions", handlers.SyncPermissionsGet)
|
||||
protected.POST("/sync/ops", handlers.SyncOpsPush)
|
||||
protected.GET("/sync/permissions", handlers.SyncPermissionsGet)
|
||||
}
|
||||
|
||||
return r
|
||||
@@ -44,6 +49,12 @@ func main() {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
authManager, err := auth.NewManager(cfg.AuthSecret)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
handlers.Auth = authManager
|
||||
|
||||
if err := newRouter().Run(fmt.Sprintf(":%d", cfg.Port)); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"aidanwoods.dev/go-paseto"
|
||||
)
|
||||
|
||||
const TokenTTL = 90 * 24 * time.Hour
|
||||
|
||||
var ErrInvalidToken = errors.New("invalid token")
|
||||
|
||||
// Manager issues and verifies paseto v4-local bearer tokens bound to a device_id.
|
||||
type Manager struct {
|
||||
key paseto.V4SymmetricKey
|
||||
}
|
||||
|
||||
func NewManager(secret string) (*Manager, error) {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
key, err := paseto.V4SymmetricKeyFromBytes(sum[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{key: key}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Issue(deviceID string) (string, error) {
|
||||
now := time.Now()
|
||||
token := paseto.NewToken()
|
||||
token.SetIssuedAt(now)
|
||||
token.SetNotBefore(now)
|
||||
token.SetExpiration(now.Add(TokenTTL))
|
||||
token.SetSubject(deviceID)
|
||||
return token.V4Encrypt(m.key, nil), nil
|
||||
}
|
||||
|
||||
func (m *Manager) Verify(signed string) (string, error) {
|
||||
parsed, err := paseto.NewParserForValidNow().ParseV4Local(m.key, signed, nil)
|
||||
if err != nil {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
subject, err := parsed.GetSubject()
|
||||
if err != nil {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
return subject, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIssueVerifyRoundtrip(t *testing.T) {
|
||||
m, err := NewManager("test-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
|
||||
deviceID := "0123456789abcdef0123456789abcdef"
|
||||
signed, err := m.Issue(deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
|
||||
got, err := m.Verify(signed)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if got != deviceID {
|
||||
t.Fatalf("Verify: got %q want %q", got, deviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTamperedToken(t *testing.T) {
|
||||
m, err := NewManager("test-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
|
||||
signed, _ := m.Issue("0123456789abcdef0123456789abcdef")
|
||||
parts := strings.Split(signed, ".")
|
||||
parts[len(parts)-1] = "nope"
|
||||
tampered := strings.Join(parts, ".")
|
||||
|
||||
if _, err := m.Verify(tampered); err == nil {
|
||||
t.Fatal("expected tampered token to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsGarbage(t *testing.T) {
|
||||
m, err := NewManager("test-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
if _, err := m.Verify("not-a-token"); err == nil {
|
||||
t.Fatal("expected garbage to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentSecretRejectsToken(t *testing.T) {
|
||||
a, _ := NewManager("secret-a")
|
||||
b, _ := NewManager("secret-b")
|
||||
|
||||
signed, _ := a.Issue("0123456789abcdef0123456789abcdef")
|
||||
if _, err := b.Verify(signed); err == nil {
|
||||
t.Fatal("expected token from another manager to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -49,4 +49,4 @@ func TestMoveDocumentToAFile(t *testing.T) {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user