feat(api): identité user-first — login/password, ownership user, fin du token device
- 000006 : users.username/username_normalized/password_hash/is_admin, index unique partiel sur username_normalized, DROP idx_users_email (unicité V1 = username, jamais email) ; 000007 destructif : resources.owner_id→user_id (FK→users, reset base dev), l'unicité racine devient par user - pkg/passwd : argon2id (t=1,m=64MiB,p=4,k=32), comparaison constant-time, VerifyTimedEqual (délai égalisé dummy-hash) ; pkg/auth : subject=user_id + claim device_id, TTL 7 j sans refresh - repository : Users (count/get/resolve-exact/create/update-password/ mark-deleted), Devices.MarkUser (user_id INFORMATIF uniquement) - handlers : POST /devices sans token, POST /auth/login (401 indistinguable user inconnu/mauvais mdp, device requis), PATCH /users/me/password (current_password, tokens non révoqués — limite V1), GET /users/resolve (exact lowercase, jamais email/is_admin) ; middleware RequireAuth (user authorisant, device porté) ; tout le scoping ressources passe user - bootstrap admin : users vide + ADMIN_* absents → refus de démarrer ; .env.example ; docs/api-v1.md §2/§3/§7/§8 - tests : migrations 000006/000007 (up/down), pkg/auth Identity, handlers login/password/resolve, helpers refactorés registerAndLogin, repo tests scopés user — suite backend verte
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
# Copier vers .env et adapter. Le fichier .env est OPTIONNEL (défauts ci-dessous).
|
||||||
|
|
||||||
|
# Serveur
|
||||||
|
PORT=8080
|
||||||
|
DATABASE_URL=postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_dev?sslmode=disable
|
||||||
|
UPLOAD_DIR=./uploads
|
||||||
|
MAX_FILE_SIZE_MB=50
|
||||||
|
OCR_LANG=fra+eng
|
||||||
|
|
||||||
|
# Sécurité — à changer en production (jamais commité)
|
||||||
|
AUTH_SECRET=dev-secret-change-me
|
||||||
|
|
||||||
|
# Premier admin (créé UNIQUEMENT si la table users est vide). Obligatoires au
|
||||||
|
# premier démarrage ; ignorés ensuite (l'env n'écrase jamais un compte).
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=change-me
|
||||||
@@ -44,6 +44,12 @@ func main() {
|
|||||||
handlers.Auth = authManager
|
handlers.Auth = authManager
|
||||||
|
|
||||||
repo := repository.NewRepository(conn)
|
repo := repository.NewRepository(conn)
|
||||||
|
|
||||||
|
// Premier admin (users vide) — refus de démarrer si identifiants absents.
|
||||||
|
if err := service.EnsureAdmin(repo, cfg.AdminUsername, cfg.AdminPassword); err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
handlers.Store = service.NewResources(
|
handlers.Store = service.NewResources(
|
||||||
repo,
|
repo,
|
||||||
cfg.UploadDir,
|
cfg.UploadDir,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
var expectedRoutes = []string{
|
var expectedRoutes = []string{
|
||||||
"GET /api/v1/health",
|
"GET /api/v1/health",
|
||||||
"POST /api/v1/devices",
|
"POST /api/v1/devices",
|
||||||
|
"POST /api/v1/auth/login",
|
||||||
|
|
||||||
"GET /api/v1/files",
|
"GET /api/v1/files",
|
||||||
"GET /api/v1/files/:id",
|
"GET /api/v1/files/:id",
|
||||||
@@ -17,6 +18,9 @@ var expectedRoutes = []string{
|
|||||||
"GET /api/v1/files/folders",
|
"GET /api/v1/files/folders",
|
||||||
"POST /api/v1/files/upload",
|
"POST /api/v1/files/upload",
|
||||||
|
|
||||||
|
"GET /api/v1/users/resolve",
|
||||||
|
"PATCH /api/v1/users/me/password",
|
||||||
|
|
||||||
"POST /api/v1/ocr/jobs",
|
"POST /api/v1/ocr/jobs",
|
||||||
"GET /api/v1/ocr/jobs/:id",
|
"GET /api/v1/ocr/jobs/:id",
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type ApplicationConfig struct {
|
|||||||
MaxFileSizeMB int64
|
MaxFileSizeMB int64
|
||||||
OcrLang string
|
OcrLang string
|
||||||
AuthSecret string
|
AuthSecret string
|
||||||
|
AdminUsername string
|
||||||
|
AdminPassword string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadApplicationConfig() (error, *ApplicationConfig) {
|
func LoadApplicationConfig() (error, *ApplicationConfig) {
|
||||||
@@ -38,6 +40,8 @@ func LoadApplicationConfig() (error, *ApplicationConfig) {
|
|||||||
MaxFileSizeMB: int64(maxSize),
|
MaxFileSizeMB: int64(maxSize),
|
||||||
OcrLang: get("OCR_LANG", "fra+eng"),
|
OcrLang: get("OCR_LANG", "fra+eng"),
|
||||||
AuthSecret: get("AUTH_SECRET", "dev-secret-change-me"),
|
AuthSecret: get("AUTH_SECRET", "dev-secret-change-me"),
|
||||||
|
AdminUsername: get("ADMIN_USERNAME", ""),
|
||||||
|
AdminPassword: get("ADMIN_PASSWORD", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_users_username_normalized;
|
||||||
|
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS is_admin;
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS password_hash;
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS username_normalized;
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS username;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email
|
||||||
|
ON users(email) WHERE email IS NOT NULL AND deleted_at IS NULL;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Identité utilisateur (tranche users + auth).
|
||||||
|
-- users.id reste TEXT 32-hex (convention du contrat V1, docs/api-v1.md §2).
|
||||||
|
-- L'unicité porte sur username_normalized (lowercase), jamais sur username
|
||||||
|
-- (forme affichée).
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS username_normalized TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- email redevient optionnel SANS unicité en V1 : le destinataire d'un partage
|
||||||
|
-- se résout par username, jamais par email.
|
||||||
|
DROP INDEX IF EXISTS idx_users_email;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_normalized
|
||||||
|
ON users(username_normalized) WHERE username_normalized IS NOT NULL;
|
||||||
|
|
||||||
|
-- devices.user_id est INFORMATIF uniquement (« dernier utilisateur connu ») :
|
||||||
|
-- il n'autorise RIEN. L'autorisation ne passe que par le claim user_id du
|
||||||
|
-- token paseto, résolu par le middleware RequireDevice.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Reverse de 000007. Réversible SEULEMENT sur base vide (les ressources sont
|
||||||
|
-- détruites dans les deux sens : sans device associé, il n'y a rien à
|
||||||
|
-- préserver).
|
||||||
|
DELETE FROM resources;
|
||||||
|
|
||||||
|
ALTER TABLE resources DROP CONSTRAINT resources_user_id_fkey;
|
||||||
|
ALTER TABLE resources RENAME COLUMN user_id TO owner_id;
|
||||||
|
ALTER TABLE resources ADD CONSTRAINT resources_owner_id_fkey
|
||||||
|
FOREIGN KEY (owner_id) REFERENCES devices(device_id) ON DELETE CASCADE;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Bascule ownership device → user (étapes 6-10 de la tranche identité).
|
||||||
|
-- Destructif ASSUMÉ : à ce stade aucune release V1, la base de dev est RESET
|
||||||
|
-- (DELETE trivial sur une table vide ; PAS de user marqueur). Le modele
|
||||||
|
-- resources reste parfaitement identique par ailleurs.
|
||||||
|
DELETE FROM resources;
|
||||||
|
|
||||||
|
ALTER TABLE resources DROP CONSTRAINT resources_owner_id_fkey;
|
||||||
|
ALTER TABLE resources RENAME COLUMN owner_id TO user_id;
|
||||||
|
ALTER TABLE resources ADD CONSTRAINT resources_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Les index créés en 000003 (idx_resources_owner, idx_resources_root_name
|
||||||
|
-- sur owner_id) suivent le renommage Intrinsèquement : ils portent désormais
|
||||||
|
-- sur user_id. L'unicité racine devient donc « 1 nom par USER » (multi-device
|
||||||
|
-- possible pour le même compte).
|
||||||
@@ -110,6 +110,74 @@ func TestMigrationsUpDown(t *testing.T) {
|
|||||||
assertHexCheck(t, conn, "devices", "device_id")
|
assertHexCheck(t, conn, "devices", "device_id")
|
||||||
assertHexCheck(t, conn, "resources", "resource_id")
|
assertHexCheck(t, conn, "resources", "resource_id")
|
||||||
assertHexCheck(t, conn, "ocr_jobs", "job_id")
|
assertHexCheck(t, conn, "ocr_jobs", "job_id")
|
||||||
|
assertHexCheck(t, conn, "users", "id")
|
||||||
|
|
||||||
|
// --- 000006 : identité utilisateur ----------------------------------
|
||||||
|
usersColumns := []string{"username", "username_normalized", "password_hash", "is_admin"}
|
||||||
|
for _, col := range usersColumns {
|
||||||
|
var tpe string
|
||||||
|
err = conn.QueryRow(`
|
||||||
|
SELECT data_type FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = $1`, col).Scan(&tpe)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("colonne users.%s manquante: %v", col, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// username_unique : index UNIQUE partiel sur username_normalized
|
||||||
|
var usernameUnique int
|
||||||
|
err = conn.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM pg_index i
|
||||||
|
JOIN pg_class t ON t.oid = i.indrelid
|
||||||
|
WHERE t.relname = 'users' AND i.indisunique
|
||||||
|
AND ARRAY(SELECT a.attname FROM unnest(i.indkey) WITH ORDINALITY k(attnum, ord)
|
||||||
|
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
||||||
|
ORDER BY k.ord)::text[] = ARRAY['username_normalized']`).Scan(&usernameUnique)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unique username_normalized: %v", err)
|
||||||
|
}
|
||||||
|
if usernameUnique == 0 {
|
||||||
|
t.Error("contrainte UNIQUE(username_normalized) manquante sur users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// idx_users_email a disparu (unicité V1 = username, jamais email)
|
||||||
|
var emailIdx int
|
||||||
|
err = conn.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM pg_index i
|
||||||
|
JOIN pg_class t ON t.oid = i.indrelid
|
||||||
|
WHERE t.relname = 'users' AND i.indisunique
|
||||||
|
AND ARRAY(SELECT a.attname FROM unnest(i.indkey) WITH ORDINALITY k(attnum, ord)
|
||||||
|
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
||||||
|
ORDER BY k.ord)::text[] = ARRAY['email']`).Scan(&emailIdx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("email idx: %v", err)
|
||||||
|
}
|
||||||
|
if emailIdx != 0 {
|
||||||
|
t.Error("index UNIQUE(email) ne doit plus exister après 000006")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 000007 : ownership ressources user ------------------------------
|
||||||
|
var plc string
|
||||||
|
err = conn.QueryRow(`
|
||||||
|
SELECT data_type FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'resources' AND column_name = 'user_id'`).Scan(&plc)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("colonne resources.user_id manquante après 000007: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resourceFkToUsers int
|
||||||
|
err = conn.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM pg_constraint c
|
||||||
|
JOIN pg_class t ON t.oid = c.conrelid
|
||||||
|
JOIN pg_class r ON r.oid = c.confrelid
|
||||||
|
WHERE t.relname = 'resources' AND r.relname = 'users' AND c.contype = 'f'
|
||||||
|
AND pg_get_constraintdef(c.oid) LIKE '%user_id%'`).Scan(&resourceFkToUsers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FK resources→users: %v", err)
|
||||||
|
}
|
||||||
|
if resourceFkToUsers == 0 {
|
||||||
|
t.Error("FK resources.user_id → users(id) manquante après 000007")
|
||||||
|
}
|
||||||
|
|
||||||
// operation_id outbox = id client (INTEGER) — cf. docs/api-v1.md §6.1
|
// operation_id outbox = id client (INTEGER) — cf. docs/api-v1.md §6.1
|
||||||
var opType string
|
var opType string
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/pkg/api"
|
||||||
|
"github.com/vaultdrop/backend/pkg/auth"
|
||||||
|
"github.com/vaultdrop/backend/pkg/passwd"
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
|
"github.com/vaultdrop/backend/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginUserDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
User LoginUserDTO `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthLogin est la SEULE porte d'émission de token (V1). L'erreur est
|
||||||
|
// volontairement indistinguable entre « username inconnu » et « mauvais mot de
|
||||||
|
// passe » — même code, même message, délai égalisé via passwd.VerifyTimedEqual.
|
||||||
|
func AuthLogin(c *gin.Context) {
|
||||||
|
if Store == nil || Store.Repository == nil || Auth == nil {
|
||||||
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Username) == "" || req.Password == "" || req.DeviceID == "" {
|
||||||
|
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "username, password and device_id are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := service.NormalizeUsername(req.Username)
|
||||||
|
user, userErr := Store.Repository.Users.GetByUsernameNormalized(normalized)
|
||||||
|
|
||||||
|
// Même durée de traitement que le compte existe ou non (username inconnu =>
|
||||||
|
// stored vide → vérification contre dummyHash).
|
||||||
|
stored := ""
|
||||||
|
if userErr == nil && user != nil {
|
||||||
|
stored = user.PasswordHash
|
||||||
|
}
|
||||||
|
if err := passwd.VerifyTimedEqual(req.Password, stored); err != nil {
|
||||||
|
if errors.Is(err, passwd.ErrMismatch) {
|
||||||
|
api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not verify credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := Store.Repository.Devices.Exists(req.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not check device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
api.Error(c, http.StatusBadRequest, "INVALID_DEVICE_ID", "device not registered")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Store.Repository.Devices.MarkUser(req.DeviceID, user.ID); err != nil {
|
||||||
|
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not update device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := Auth.Issue(user.ID, req.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(c, http.StatusInternalServerError, "TOKEN_ERROR", "could not issue token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.OK(c, LoginResponse{
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: time.Now().Add(auth.TokenTTL).UnixMilli(),
|
||||||
|
User: LoginUserDTO{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
IsAdmin: user.IsAdmin,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type changePasswordRequest struct {
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword met à jour le hash du compte connecté. Le mot de passe
|
||||||
|
// courant est requis. Limite connue V1 : les tokens déjà émis restent valides
|
||||||
|
// jusqu'à expiration (7 j, pas de révocation) — cf. docs/api-v1.md §7.
|
||||||
|
func ChangePassword(c *gin.Context) {
|
||||||
|
if Store == nil || Store.Repository == nil {
|
||||||
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := c.GetString(UserIDKey)
|
||||||
|
|
||||||
|
var req changePasswordRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.NewPassword) < 8 {
|
||||||
|
api.Error(c, http.StatusBadRequest, "INVALID_PASSWORD", "new password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := Store.Repository.Users.GetByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := passwd.Verify(req.CurrentPassword, user.PasswordHash); err != nil {
|
||||||
|
api.Error(c, http.StatusForbidden, "INVALID_PASSWORD", "current password is incorrect")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash, err := passwd.Hash(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not hash new password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := Store.Repository.Users.UpdatePassword(userID, newHash); err != nil {
|
||||||
|
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not update password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.OK(c, gin.H{"id": userID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveUser résout UN destinataire par username EXACT (normalisé lowercase).
|
||||||
|
// Jamais de listing ni de préfixe (pas d'énumération de comptes). Ne renvoie
|
||||||
|
// que {id, username} — jamais email/is_admin/created_at.
|
||||||
|
func ResolveUser(c *gin.Context) {
|
||||||
|
if Store == nil || Store.Repository == nil {
|
||||||
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := service.NormalizeUsername(c.Query("username"))
|
||||||
|
if username == "" {
|
||||||
|
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "username is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := Store.Repository.Users.ResolveExact(username)
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
api.Error(c, http.StatusNotFound, "NOT_FOUND", "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not resolve user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.OK(c, gin.H{"id": user.ID, "username": user.Username})
|
||||||
|
}
|
||||||
+263
-62
@@ -11,19 +11,22 @@ import (
|
|||||||
|
|
||||||
"github.com/vaultdrop/backend/dbtest"
|
"github.com/vaultdrop/backend/dbtest"
|
||||||
"github.com/vaultdrop/backend/pkg/auth"
|
"github.com/vaultdrop/backend/pkg/auth"
|
||||||
|
"github.com/vaultdrop/backend/pkg/passwd"
|
||||||
"github.com/vaultdrop/backend/repository"
|
"github.com/vaultdrop/backend/repository"
|
||||||
"github.com/vaultdrop/backend/service"
|
"github.com/vaultdrop/backend/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
const authTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_handlers_auth_test?sslmode=disable"
|
const authTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_handlers_auth_test?sslmode=disable"
|
||||||
|
|
||||||
|
const (
|
||||||
|
authTestPassword = "vaultdrop-test-password"
|
||||||
|
authTestDevice = "0123456789abcdef0123456789abcdef"
|
||||||
|
)
|
||||||
|
|
||||||
func newTestRouterForAuth() *gin.Engine {
|
func newTestRouterForAuth() *gin.Engine {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.POST("/devices", DevicesRegister)
|
RegisterRoutes(r)
|
||||||
grp := r.Group("")
|
|
||||||
grp.Use(RequireDevice)
|
|
||||||
grp.GET("/files", FilesList)
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,93 +39,291 @@ func setTestStore(t *testing.T) *repository.Repository {
|
|||||||
return repo
|
return repo
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevicesRegisterValid(t *testing.T) {
|
func setTestAuth(t *testing.T) {
|
||||||
m, _ := auth.NewManager("test-secret")
|
t.Helper()
|
||||||
Auth = m
|
Auth, _ = auth.NewManager("test-secret")
|
||||||
defer func() { Auth = nil }()
|
t.Cleanup(func() { Auth = nil })
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAdmin(t *testing.T, repo *repository.Repository, username, password string) string {
|
||||||
|
t.Helper()
|
||||||
|
hash, err := passwd.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
id, err := repo.Users.Create(service.NormalizeUsername(username), username, hash, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func doAuth(t *testing.T, r *gin.Engine, method, path, token string, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
if body != "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginEnvelope struct {
|
||||||
|
Data struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
} `json:"user"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginOK(t *testing.T, r *gin.Engine, username, password, deviceID string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
body := `{"username":` + mustJSON(username) + `,"password":` + mustJSON(password) + `,"device_id":` + mustJSON(deviceID) + `}`
|
||||||
|
rec := doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", body)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("login: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var env loginEnvelope
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||||
|
t.Fatalf("login: unmarshal %v body %s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if env.Data.Token == "" || env.Data.User.ID == "" {
|
||||||
|
t.Fatalf("login: token/user manquants %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
return env.Data.Token, env.Data.User.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(s string) string {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevicesRegisterReturnsNoToken(t *testing.T) {
|
||||||
|
setTestAuth(t)
|
||||||
repo := setTestStore(t)
|
repo := setTestStore(t)
|
||||||
|
|
||||||
deviceID := "0123456789abcdef0123456789abcdef"
|
rec := doAuth(t, newTestRouterForAuth(), http.MethodPost, "/api/v1/devices", "", `{"deviceId":"`+authTestDevice+`"}`)
|
||||||
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 {
|
if rec.Code != 200 {
|
||||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
var env struct {
|
||||||
var envelope struct {
|
Data map[string]any `json:"data"`
|
||||||
Data struct {
|
|
||||||
DeviceID string `json:"deviceId"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||||
t.Fatalf("unmarshal: %v", err)
|
if _, hasToken := env.Data["token"]; hasToken {
|
||||||
|
t.Error("POST /devices ne doit plus émettre de token")
|
||||||
}
|
}
|
||||||
if envelope.Data.DeviceID != deviceID {
|
if env.Data["deviceId"] != authTestDevice {
|
||||||
t.Errorf("deviceId = %q", envelope.Data.DeviceID)
|
t.Errorf("deviceId = %v", env.Data["deviceId"])
|
||||||
}
|
}
|
||||||
|
|
||||||
verified, err := m.Verify(envelope.Data.Token)
|
exists, err := repo.Devices.Exists(authTestDevice)
|
||||||
if err != nil || verified != deviceID {
|
|
||||||
t.Errorf("token invalid: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Le device est bien persisté (requis par les FK resources.owner_id).
|
|
||||||
exists, err := repo.Devices.Exists(deviceID)
|
|
||||||
if err != nil || !exists {
|
if err != nil || !exists {
|
||||||
t.Errorf("device non persisté: exists=%v err=%v", exists, err)
|
t.Fatalf("device non persisté: exists=%v err=%v", exists, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevicesRegisterRejectsBadDeviceID(t *testing.T) {
|
func TestDevicesRegisterRejectsBadDeviceID(t *testing.T) {
|
||||||
Auth, _ = auth.NewManager("test-secret")
|
setTestAuth(t)
|
||||||
defer func() { Auth = nil }()
|
setTestStore(t)
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/devices", strings.NewReader(`{"deviceId":"UPPERCASEANDTOOLONG"}`))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
newTestRouterForAuth().ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
|
rec := doAuth(t, newTestRouterForAuth(), http.MethodPost, "/api/v1/devices", "", `{"deviceId":"UPPERCASEANDTOOLONG"}`)
|
||||||
if rec.Code != 400 {
|
if rec.Code != 400 {
|
||||||
t.Fatalf("status = %d", rec.Code)
|
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequireDeviceRejectsMissingToken(t *testing.T) {
|
func TestRequireAuthRejectsMissingToken(t *testing.T) {
|
||||||
Auth, _ = auth.NewManager("test-secret")
|
setTestAuth(t)
|
||||||
defer func() { Auth = nil }()
|
setTestStore(t)
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/files", nil)
|
|
||||||
newTestRouterForAuth().ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
|
rec := doAuth(t, newTestRouterForAuth(), http.MethodGet, "/api/v1/files", "", "")
|
||||||
if rec.Code != 401 {
|
if rec.Code != 401 {
|
||||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequireDeviceAcceptsValidToken(t *testing.T) {
|
func TestAuthLoginFlow(t *testing.T) {
|
||||||
Auth, _ = auth.NewManager("test-secret")
|
setTestAuth(t)
|
||||||
defer func() { Auth = nil }()
|
repo := setTestStore(t)
|
||||||
|
adminID := createAdmin(t, repo, "admin", authTestPassword)
|
||||||
|
r := newTestRouterForAuth()
|
||||||
|
|
||||||
deviceID := "0123456789abcdef0123456789abcdef"
|
// Enregistrement du device puis login → token utilisable
|
||||||
signed, err := Auth.Issue(deviceID)
|
rec := doAuth(t, r, http.MethodPost, "/api/v1/devices", "", `{"deviceId":"`+authTestDevice+`"}`)
|
||||||
if err != nil {
|
if rec.Code != 200 {
|
||||||
t.Fatalf("Issue: %v", err)
|
t.Fatalf("register: status %d", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
token, userID := loginOK(t, r, "admin", authTestPassword, authTestDevice)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/files", nil)
|
if userID != adminID {
|
||||||
req.Header.Set("Authorization", "Bearer "+signed)
|
t.Errorf("login user.id = %q, want %q", userID, adminID)
|
||||||
newTestRouterForAuth().ServeHTTP(rec, req)
|
}
|
||||||
|
|
||||||
// Sans Store le handler répond SERVICE_UNAVAILABLE (503) — le point est
|
// Le token fonctionne sur un endpoint protégé
|
||||||
// que la requête a dépassé le middleware (jamais 401).
|
rec = doAuth(t, r, http.MethodGet, "/api/v1/files", token, "")
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Errorf("files protégé avec token = %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLoginWrongCredentials(t *testing.T) {
|
||||||
|
setTestAuth(t)
|
||||||
|
repo := setTestStore(t)
|
||||||
|
createAdmin(t, repo, "admin", authTestPassword)
|
||||||
|
r := newTestRouterForAuth()
|
||||||
|
|
||||||
|
doAuth(t, r, http.MethodPost, "/api/v1/devices", "", `{"deviceId":"`+authTestDevice+`"}`)
|
||||||
|
|
||||||
|
// Mauvais mot de passe
|
||||||
|
rec := doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"wrong-password","device_id":"`+authTestDevice+`"}`)
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Fatalf("mauvais mot de passe: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "UNAUTHORIZED") {
|
||||||
|
t.Errorf("code attendu UNAUTHORIZED, got %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username inconnu → MÊME réponse (indistinguable)
|
||||||
|
rec2 := doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"ghost","password":"wrong-password","device_id":"`+authTestDevice+`"}`)
|
||||||
|
if rec2.Code != 401 {
|
||||||
|
t.Fatalf("username inconnu: status %d body %s", rec2.Code, rec2.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Body.String() != rec2.Body.String() {
|
||||||
|
t.Errorf("réponses distinguables (sécurité):\n%q\n%q", rec.Body.String(), rec2.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLoginUnregisteredDevice(t *testing.T) {
|
||||||
|
setTestAuth(t)
|
||||||
|
repo := setTestStore(t)
|
||||||
|
createAdmin(t, repo, "admin", authTestPassword)
|
||||||
|
r := newTestRouterForAuth()
|
||||||
|
|
||||||
|
rec := doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"`+authTestPassword+`","device_id":"`+authTestDevice+`"}`)
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Fatalf("device non enregistré: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "INVALID_DEVICE_ID") {
|
||||||
|
t.Errorf("code attendu INVALID_DEVICE_ID, got %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthRejectsTokenForDeletedAccount(t *testing.T) {
|
||||||
|
setTestAuth(t)
|
||||||
|
repo := setTestStore(t)
|
||||||
|
userID := createAdmin(t, repo, "admin", authTestPassword)
|
||||||
|
_ = repo.Devices.Upsert(authTestDevice)
|
||||||
|
_ = repo.Devices.MarkUser(authTestDevice, userID)
|
||||||
|
r := newTestRouterForAuth()
|
||||||
|
|
||||||
|
signed, _ := Auth.Issue(userID, authTestDevice)
|
||||||
|
requireAdmin(t, r, signed)
|
||||||
|
|
||||||
|
// Suppression du compte → le token ne passe plus le middleware
|
||||||
|
if err := repo.Users.MarkDeleted(userID); err != nil {
|
||||||
|
t.Fatalf("mark deleted: %v", err)
|
||||||
|
}
|
||||||
|
rec := doAuth(t, r, http.MethodGet, "/api/v1/files", signed, "")
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Errorf("compte supprimé: status attendu 401, got %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireAdmin(t *testing.T, r *gin.Engine, token string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := doAuth(t, r, http.MethodGet, "/api/v1/files", token, "")
|
||||||
if rec.Code == 401 {
|
if rec.Code == 401 {
|
||||||
t.Fatalf("middleware a rejeté un token valide: %s", rec.Body.String())
|
t.Fatalf("token valide rejeté: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePassword(t *testing.T) {
|
||||||
|
setTestAuth(t)
|
||||||
|
repo := setTestStore(t)
|
||||||
|
createAdmin(t, repo, "admin", authTestPassword)
|
||||||
|
_ = repo.Devices.Upsert(authTestDevice)
|
||||||
|
|
||||||
|
r := newTestRouterForAuth()
|
||||||
|
token, _ := loginOK(t, r, "admin", authTestPassword, authTestDevice)
|
||||||
|
|
||||||
|
// Mauvais mot de passe courant → 403
|
||||||
|
rec := doAuth(t, r, http.MethodPatch, "/api/v1/users/me/password", token, `{"current_password":"nope","new_password":"new-secret-123"}`)
|
||||||
|
if rec.Code != 403 {
|
||||||
|
t.Fatalf("mauvais current_password: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthode non autorisée ayant échouée → 403 ne doit PAS confirmer le compte
|
||||||
|
if !strings.Contains(rec.Body.String(), "INVALID_PASSWORD") {
|
||||||
|
t.Errorf("code INVALID_PASSWORD attendu, got %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changement valide
|
||||||
|
rec = doAuth(t, r, http.MethodPatch, "/api/v1/users/me/password", token, `{"current_password":"`+authTestPassword+`","new_password":"new-secret-123"}`)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("change password: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'ancien mot de passe ne passe plus…
|
||||||
|
rec = doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"`+authTestPassword+`","device_id":"`+authTestDevice+`"}`)
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Errorf("ancien mdp encore accepté: status %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// … et le nouveau donne un token (tokens précédents restent valides : limite V1)
|
||||||
|
rec = doAuth(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"new-secret-123","device_id":"`+authTestDevice+`"}`)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Errorf("nouveau mdp refusé: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUser(t *testing.T) {
|
||||||
|
setTestAuth(t)
|
||||||
|
repo := setTestStore(t)
|
||||||
|
createAdmin(t, repo, "admin", authTestPassword)
|
||||||
|
createAdmin(t, repo, "alice", authTestPassword)
|
||||||
|
_ = repo.Devices.Upsert(authTestDevice)
|
||||||
|
_ = repo.Devices.MarkUser(authTestDevice, "admin")
|
||||||
|
r := newTestRouterForAuth()
|
||||||
|
token, _ := loginOK(t, r, "admin", authTestPassword, authTestDevice)
|
||||||
|
|
||||||
|
// Résolution exacte, insensible à la casse / aux espaces (%20 décodé puis trim)
|
||||||
|
rec := doAuth(t, r, http.MethodGet, "/api/v1/users/resolve?username=ALICE%20", token, "")
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("resolve: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Data struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||||
|
if env.Data.Username != "alice" {
|
||||||
|
t.Errorf("resolve: %+v", env.Data)
|
||||||
|
}
|
||||||
|
// Jamais email ni is_admin
|
||||||
|
if len(rec.Body.Bytes()) < 0 || strings.Contains(rec.Body.String(), "is_admin") || strings.Contains(rec.Body.String(), "email") {
|
||||||
|
t.Error("resolve ne doit pas exposer email/is_admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inconnu → 404
|
||||||
|
rec = doAuth(t, r, http.MethodGet, "/api/v1/users/resolve?username=ghost", token, "")
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Errorf("resolve inconnu: status %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sans token → 401
|
||||||
|
rec = doAuth(t, r, http.MethodGet, "/api/v1/users/resolve?username=admin", "", "")
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Errorf("resolve sans token: status %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,10 @@ func DevicesRegister(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := Auth.Issue(req.DeviceID)
|
// POST /devices n'émet PLUS de token : de l'identité user-first (V1), le
|
||||||
if err != nil {
|
// device s'enregistre pour exister, puis le client appelle POST /auth/login
|
||||||
api.Error(c, 500, "TOKEN_ERROR", "could not issue token")
|
// avec username/password + device_id pour obtenir son token (cf.
|
||||||
return
|
// docs/api-v1.md §2). Réponse : { deviceId } uniquement.
|
||||||
}
|
api.OK(c, gin.H{"deviceId": req.DeviceID})
|
||||||
|
|
||||||
api.OK(c, gin.H{"deviceId": req.DeviceID, "token": token})
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -26,13 +26,13 @@ func FilesList(c *gin.Context) {
|
|||||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceID := c.GetString(DeviceIDKey)
|
userID := c.GetString(UserIDKey)
|
||||||
page := intParam(c.Query("page"), 1)
|
page := intParam(c.Query("page"), 1)
|
||||||
pageSize := intParam(c.Query("pageSize"), 50)
|
pageSize := intParam(c.Query("pageSize"), 50)
|
||||||
if pageSize > 200 {
|
if pageSize > 200 {
|
||||||
pageSize = 200
|
pageSize = 200
|
||||||
}
|
}
|
||||||
files, total, err := Store.ListFiles(deviceID, c.Query("folderId"), page, pageSize, c.Query("sort"), c.Query("order"))
|
files, total, err := Store.ListFiles(userID, c.Query("folderId"), page, pageSize, c.Query("sort"), c.Query("order"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@@ -45,13 +45,13 @@ func FilesGet(c *gin.Context) {
|
|||||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceID := c.GetString(DeviceIDKey)
|
userID := c.GetString(UserIDKey)
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
if !deviceIDPattern.MatchString(id) {
|
if !deviceIDPattern.MatchString(id) {
|
||||||
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
|
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, err := Store.GetFile(deviceID, id)
|
file, err := Store.GetFile(userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@@ -64,13 +64,13 @@ func FilesDelete(c *gin.Context) {
|
|||||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceID := c.GetString(DeviceIDKey)
|
userID := c.GetString(UserIDKey)
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
if !deviceIDPattern.MatchString(id) {
|
if !deviceIDPattern.MatchString(id) {
|
||||||
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
|
api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deletedID, err := Store.DeleteFile(deviceID, id)
|
deletedID, err := Store.DeleteFile(userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@@ -83,7 +83,7 @@ func FilesUpload(c *gin.Context) {
|
|||||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceID := c.GetString(DeviceIDKey)
|
userID := c.GetString(UserIDKey)
|
||||||
folderID := c.PostForm("folderId")
|
folderID := c.PostForm("folderId")
|
||||||
|
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, Store.MaxFileSize+1)
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, Store.MaxFileSize+1)
|
||||||
@@ -102,7 +102,7 @@ func FilesUpload(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dto, err := Store.Upload(deviceID, file, folderID)
|
dto, err := Store.Upload(userID, file, folderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@@ -115,8 +115,8 @@ func FoldersList(c *gin.Context) {
|
|||||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceID := c.GetString(DeviceIDKey)
|
userID := c.GetString(UserIDKey)
|
||||||
folders, err := Store.ListRootFolders(deviceID)
|
folders, err := Store.ListRootFolders(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@@ -129,7 +129,7 @@ func FilesSearch(c *gin.Context) {
|
|||||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceID := c.GetString(DeviceIDKey)
|
userID := c.GetString(UserIDKey)
|
||||||
q := strings.TrimSpace(c.Query("q"))
|
q := strings.TrimSpace(c.Query("q"))
|
||||||
if q == "" {
|
if q == "" {
|
||||||
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "missing required query param `q`")
|
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "missing required query param `q`")
|
||||||
@@ -140,7 +140,7 @@ func FilesSearch(c *gin.Context) {
|
|||||||
if pageSize > 200 {
|
if pageSize > 200 {
|
||||||
pageSize = 200
|
pageSize = 200
|
||||||
}
|
}
|
||||||
files, total, err := Store.SearchFiles(deviceID, q, page, pageSize)
|
files, total, err := Store.SearchFiles(userID, q, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/vaultdrop/backend/dbtest"
|
"github.com/vaultdrop/backend/dbtest"
|
||||||
"github.com/vaultdrop/backend/handlers"
|
"github.com/vaultdrop/backend/handlers"
|
||||||
"github.com/vaultdrop/backend/pkg/auth"
|
"github.com/vaultdrop/backend/pkg/auth"
|
||||||
|
"github.com/vaultdrop/backend/pkg/passwd"
|
||||||
"github.com/vaultdrop/backend/repository"
|
"github.com/vaultdrop/backend/repository"
|
||||||
"github.com/vaultdrop/backend/service"
|
"github.com/vaultdrop/backend/service"
|
||||||
)
|
)
|
||||||
@@ -110,22 +111,53 @@ func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, code,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerDevice(t *testing.T, r *gin.Engine, deviceID string) string {
|
func registerAndLogin(t *testing.T, r *gin.Engine, repo *repository.Repository, username, password, deviceID string) (token, userID string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body := fmt.Sprintf(`{"deviceId":%q}`, deviceID)
|
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/devices", "", []byte(fmt.Sprintf(`{"deviceId":%q}`, deviceID)), "application/json")
|
||||||
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/devices", "", []byte(body), "application/json")
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("register: status %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
username = service.NormalizeUsername(username)
|
||||||
|
if _, err := repo.Users.GetByUsernameNormalized(username); err == repository.ErrNotFound {
|
||||||
|
hash, herr := passwd.Hash(password)
|
||||||
|
if herr != nil {
|
||||||
|
t.Fatalf("hash: %v", herr)
|
||||||
|
}
|
||||||
|
if _, cerr := repo.Users.Create(username, username, hash, false); cerr != nil {
|
||||||
|
t.Fatalf("create user: %v", cerr)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
t.Fatalf("get user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := fmt.Sprintf(`{"username":%q,"password":%q,"device_id":%q}`, username, password, deviceID)
|
||||||
|
rec, _ = doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", []byte(body), "application/json")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login: status %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
var env struct {
|
var env struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"user"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||||
t.Fatalf("register: unmarshal: %v body=%s", err, rec.Body.String())
|
t.Fatalf("login: unmarshal: %v body=%s", err, rec.Body.String())
|
||||||
}
|
}
|
||||||
if env.Data.Token == "" {
|
if env.Data.Token == "" || env.Data.User.ID == "" {
|
||||||
t.Fatalf("register: pas de token (status %d)", rec.Code)
|
t.Fatalf("login: token/user manquant %s", rec.Body.String())
|
||||||
}
|
}
|
||||||
return env.Data.Token
|
return env.Data.Token, env.Data.User.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// testUserUsername fournit un username unique par device (les bases de test
|
||||||
|
// sont reset, mais deux devices d'un même test ne doivent pas partager un
|
||||||
|
// compte).
|
||||||
|
func testUserUsername(deviceID, suffix string) string {
|
||||||
|
return "u" + suffix + "-" + deviceID[:8]
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadMultipart(t *testing.T, r *gin.Engine, token, folderID, filename string, content []byte) *httptest.ResponseRecorder {
|
func uploadMultipart(t *testing.T, r *gin.Engine, token, folderID, filename string, content []byte) *httptest.ResponseRecorder {
|
||||||
@@ -155,11 +187,13 @@ func TestFilesFlow(t *testing.T) {
|
|||||||
r, _, repo := setup(t)
|
r, _, repo := setup(t)
|
||||||
deviceA := repository.NewID()
|
deviceA := repository.NewID()
|
||||||
deviceB := repository.NewID()
|
deviceB := repository.NewID()
|
||||||
tokenA := registerDevice(t, r, deviceA)
|
passA := "files-test-password-a"
|
||||||
tokenB := registerDevice(t, r, deviceB)
|
passB := "files-test-password-b"
|
||||||
|
tokenA, userA := registerAndLogin(t, r, repo, testUserUsername(deviceA, "a"), passA, deviceA)
|
||||||
|
tokenB, userB := registerAndLogin(t, r, repo, testUserUsername(deviceB, "b"), passB, deviceB)
|
||||||
|
|
||||||
folderID := repository.NewID()
|
folderID := repository.NewID()
|
||||||
if err := repo.Resources.InsertFolder(deviceA, folderID, "Docs", ""); err != nil {
|
if err := repo.Resources.InsertFolder(userA, folderID, "Docs", ""); err != nil {
|
||||||
t.Fatalf("insert root folder: %v", err)
|
t.Fatalf("insert root folder: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,8 +222,8 @@ func TestFilesFlow(t *testing.T) {
|
|||||||
rec = uploadMultipart(t, r, tokenA, folderID, "doc.txt", []byte("doc"))
|
rec = uploadMultipart(t, r, tokenA, folderID, "doc.txt", []byte("doc"))
|
||||||
expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe")
|
expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe")
|
||||||
|
|
||||||
// Création de fichiers du device B
|
// Création de fichiers du user B
|
||||||
if err := repo.Resources.InsertFile(deviceB, repository.NewID(), "secret.txt", "", 4, nil, nil); err != nil {
|
if err := repo.Resources.InsertFile(userB, repository.NewID(), "secret.txt", "", 4, nil, nil); err != nil {
|
||||||
t.Fatalf("insert B file: %v", err)
|
t.Fatalf("insert B file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +252,7 @@ func TestFilesFlow(t *testing.T) {
|
|||||||
t.Errorf("liste dossier: %+v", files)
|
t.Errorf("liste dossier: %+v", files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// B ne voit pas les fichiers de A
|
// B (autre user) ne voit pas les fichiers de A
|
||||||
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+uploaded.ID, tokenB, nil, "")
|
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+uploaded.ID, tokenB, nil, "")
|
||||||
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-cross-device")
|
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-cross-device")
|
||||||
|
|
||||||
@@ -252,15 +286,15 @@ func TestFilesFlow(t *testing.T) {
|
|||||||
func TestSearchFiles(t *testing.T) {
|
func TestSearchFiles(t *testing.T) {
|
||||||
r, _, repo := setup(t)
|
r, _, repo := setup(t)
|
||||||
device := repository.NewID()
|
device := repository.NewID()
|
||||||
token := registerDevice(t, r, device)
|
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "s"), "search-test-password", device)
|
||||||
|
|
||||||
if err := repo.Resources.InsertFile(device, repository.NewID(), "vacances-août.jpg", "", 100, nil, nil); err != nil {
|
if err := repo.Resources.InsertFile(user, repository.NewID(), "vacances-août.jpg", "", 100, nil, nil); err != nil {
|
||||||
t.Fatalf("insert: %v", err)
|
t.Fatalf("insert: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.Resources.InsertFile(device, repository.NewID(), "rapport-q3.pdf", "", 100, nil, nil); err != nil {
|
if err := repo.Resources.InsertFile(user, repository.NewID(), "rapport-q3.pdf", "", 100, nil, nil); err != nil {
|
||||||
t.Fatalf("insert: %v", err)
|
t.Fatalf("insert: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.Resources.InsertFile(device, repository.NewID(), "toto.txt", "", 100, nil, nil); err != nil {
|
if err := repo.Resources.InsertFile(user, repository.NewID(), "toto.txt", "", 100, nil, nil); err != nil {
|
||||||
t.Fatalf("insert: %v", err)
|
t.Fatalf("insert: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,9 +329,9 @@ func TestSearchFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUploadTooLarge(t *testing.T) {
|
func TestUploadTooLarge(t *testing.T) {
|
||||||
r, _, _ := setup(t)
|
r, _, repo := setup(t)
|
||||||
device := repository.NewID()
|
device := repository.NewID()
|
||||||
token := registerDevice(t, r, device)
|
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "l"), "upload-test-password", device)
|
||||||
|
|
||||||
rec := uploadMultipart(t, r, token, "", "big.txt", []byte("0123456789ABCDEF"))
|
rec := uploadMultipart(t, r, token, "", "big.txt", []byte("0123456789ABCDEF"))
|
||||||
expectError(t, rec, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "upload-big")
|
expectError(t, rec, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "upload-big")
|
||||||
@@ -307,16 +341,16 @@ func TestFoldersListAndScoping(t *testing.T) {
|
|||||||
r, _, repo := setup(t)
|
r, _, repo := setup(t)
|
||||||
deviceA := repository.NewID()
|
deviceA := repository.NewID()
|
||||||
deviceB := repository.NewID()
|
deviceB := repository.NewID()
|
||||||
tokenA := registerDevice(t, r, deviceA)
|
tokenA, userA := registerAndLogin(t, r, repo, testUserUsername(deviceA, "fa"), "folder-test-password", deviceA)
|
||||||
registerDevice(t, r, deviceB)
|
_, userB := registerAndLogin(t, r, repo, testUserUsername(deviceB, "fb"), "folder-test-password-b", deviceB)
|
||||||
|
|
||||||
if err := repo.Resources.InsertFolder(deviceA, repository.NewID(), "AA", ""); err != nil {
|
if err := repo.Resources.InsertFolder(userA, repository.NewID(), "AA", ""); err != nil {
|
||||||
t.Fatalf("insert folder: %v", err)
|
t.Fatalf("insert folder: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.Resources.InsertFolder(deviceA, repository.NewID(), "BB", ""); err != nil {
|
if err := repo.Resources.InsertFolder(userA, repository.NewID(), "BB", ""); err != nil {
|
||||||
t.Fatalf("insert folder: %v", err)
|
t.Fatalf("insert folder: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.Resources.InsertFolder(deviceB, repository.NewID(), "CC", ""); err != nil {
|
if err := repo.Resources.InsertFolder(userB, repository.NewID(), "CC", ""); err != nil {
|
||||||
t.Fatalf("insert folder B: %v", err)
|
t.Fatalf("insert folder B: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,64 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/vaultdrop/backend/pkg/api"
|
"github.com/vaultdrop/backend/pkg/api"
|
||||||
"github.com/vaultdrop/backend/pkg/auth"
|
"github.com/vaultdrop/backend/pkg/auth"
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DeviceIDKey = "device_id"
|
const (
|
||||||
|
UserIDKey = "user_id"
|
||||||
|
DeviceIDKey = "device_id"
|
||||||
|
)
|
||||||
|
|
||||||
// Auth issues/verifies device bearer tokens; set once at startup (cmd/server).
|
// Auth issues/verifies device bearer tokens; set once at startup (cmd/server).
|
||||||
var Auth *auth.Manager
|
var Auth *auth.Manager
|
||||||
|
|
||||||
// RequireDevice authenticates the bearer paseto token and stores the resolved
|
// RequireAuth authenticates the bearer paseto token. Le subject (user_id)
|
||||||
// device_id in the gin context (cf. docs/api-v1.md §2).
|
// autorise — c'est la clé de scoping de toutes les ressources ; device_id est
|
||||||
func RequireDevice(c *gin.Context) {
|
// porté (non autorisant) pour l'idempotence outbox et les jobs OCR. Le compte
|
||||||
|
// doit encore exister (cf. docs/api-v1.md §2).
|
||||||
|
func RequireAuth(c *gin.Context) {
|
||||||
|
|
||||||
header := c.GetHeader("Authorization")
|
header := c.GetHeader("Authorization")
|
||||||
token, found := strings.CutPrefix(header, "Bearer ")
|
token, found := strings.CutPrefix(header, "Bearer ")
|
||||||
|
|
||||||
if Auth == nil || !found || token == "" {
|
if Auth == nil || !found || token == "" {
|
||||||
api.Error(c, 401, "UNAUTHORIZED", "missing bearer token")
|
api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "missing bearer token")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceID, err := Auth.Verify(token)
|
identity, err := Auth.Verify(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.Error(c, 401, "UNAUTHORIZED", "invalid or expired token")
|
api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired token")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set(DeviceIDKey, deviceID)
|
// Le store peut être nil dans les tests middleware purs ; sinon on vérifie
|
||||||
|
// que le compte existe toujours (supprimé → accès refusé).
|
||||||
|
if Store != nil && Store.Repository != nil {
|
||||||
|
_, err := Store.Repository.Users.GetByID(identity.UserID)
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "account no longer exists")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not load account")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(UserIDKey, identity.UserID)
|
||||||
|
c.Set(DeviceIDKey, identity.DeviceID)
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func OcrJobsCreate(c *gin.Context) {
|
|||||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceID := c.GetString(DeviceIDKey)
|
userID := c.GetString(UserIDKey)
|
||||||
|
|
||||||
var req ocrJobRequest
|
var req ocrJobRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -26,7 +26,7 @@ func OcrJobsCreate(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
job, err := Ocr.Create(deviceID, req.FileID)
|
job, err := Ocr.Create(userID, c.GetString(DeviceIDKey), req.FileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func (s stubEngine) ExtractText(_ context.Context, _ string, _ string) (string,
|
|||||||
return s.text, nil
|
return s.text, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupOcr(t *testing.T) (*gin.Engine, string) {
|
func setupOcr(t *testing.T) (*gin.Engine, string, *repository.Repository) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
conn := dbtest.OpenTestDatabase(t, handlersTestURL)
|
conn := dbtest.OpenTestDatabase(t, handlersTestURL)
|
||||||
repo := repository.NewRepository(conn)
|
repo := repository.NewRepository(conn)
|
||||||
@@ -45,7 +45,7 @@ func setupOcr(t *testing.T) (*gin.Engine, string) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
handlers.RegisterRoutes(r)
|
handlers.RegisterRoutes(r)
|
||||||
return r, uploadDir
|
return r, uploadDir, repo
|
||||||
}
|
}
|
||||||
|
|
||||||
type ocrJobDTO struct {
|
type ocrJobDTO struct {
|
||||||
@@ -75,20 +75,20 @@ func waitTerminal(t *testing.T, r *gin.Engine, token, jobID string) ocrJobDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestOcrJobsLifecycle(t *testing.T) {
|
func TestOcrJobsLifecycle(t *testing.T) {
|
||||||
r, uploadDir := setupOcr(t)
|
r, uploadDir, repo := setupOcr(t)
|
||||||
device := repository.NewID()
|
device := repository.NewID()
|
||||||
token := registerDevice(t, r, device)
|
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "oa"), "ocr-test-password", device)
|
||||||
|
|
||||||
// Fichier + fichier physique (simule UPLOAD_DIR/<device>/<id>.txt)
|
// Fichier + fichier physique (simule UPLOAD_DIR/<user>/<id>.txt — après
|
||||||
|
// 000007 le répertoire d'upload est scopé par USER)
|
||||||
fileID := repository.NewID()
|
fileID := repository.NewID()
|
||||||
if err := os.MkdirAll(filepath.Join(uploadDir, device), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(uploadDir, user), 0o755); err != nil {
|
||||||
t.Fatalf("mkdir: %v", err)
|
t.Fatalf("mkdir: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(uploadDir, device, fileID+".txt"), []byte("ignored by stub"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(uploadDir, user, fileID+".txt"), []byte("ignored by stub"), 0o644); err != nil {
|
||||||
t.Fatalf("write: %v", err)
|
t.Fatalf("write: %v", err)
|
||||||
}
|
}
|
||||||
repo := handlers.Store.Repository
|
if err := repo.Resources.InsertFile(user, fileID, "scan.png", "", 128, nil, nil); err != nil {
|
||||||
if err := repo.Resources.InsertFile(device, fileID, "scan.png", "", 128, nil, nil); err != nil {
|
|
||||||
t.Fatalf("insert: %v", err)
|
t.Fatalf("insert: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,15 +115,15 @@ func TestOcrJobsLifecycle(t *testing.T) {
|
|||||||
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-missing-file")
|
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-missing-file")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOcrJobsScopedByDevice(t *testing.T) {
|
func TestOcrJobsScopedByOwner(t *testing.T) {
|
||||||
r, _ := setupOcr(t)
|
r, _, repo := setupOcr(t)
|
||||||
deviceA := repository.NewID()
|
deviceA := repository.NewID()
|
||||||
deviceB := repository.NewID()
|
deviceB := repository.NewID()
|
||||||
tokenA := registerDevice(t, r, deviceA)
|
tokenA, userA := registerAndLogin(t, r, repo, testUserUsername(deviceA, "oc"), "ocr-test-password", deviceA)
|
||||||
tokenB := registerDevice(t, r, deviceB)
|
tokenB, _ := registerAndLogin(t, r, repo, testUserUsername(deviceB, "od"), "ocr-test-password-b", deviceB)
|
||||||
|
|
||||||
fileID := repository.NewID()
|
fileID := repository.NewID()
|
||||||
if err := handlers.Store.Repository.Resources.InsertFile(deviceA, fileID, "scan.png", "", 128, nil, nil); err != nil {
|
if err := repo.Resources.InsertFile(userA, fileID, "scan.png", "", 128, nil, nil); err != nil {
|
||||||
t.Fatalf("insert: %v", err)
|
t.Fatalf("insert: %v", err)
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(map[string]string{"fileId": fileID})
|
body, _ := json.Marshal(map[string]string{"fileId": fileID})
|
||||||
@@ -132,9 +132,9 @@ func TestOcrJobsScopedByDevice(t *testing.T) {
|
|||||||
var created ocrJobDTO
|
var created ocrJobDTO
|
||||||
_ = json.Unmarshal(env.Data, &created)
|
_ = json.Unmarshal(env.Data, &created)
|
||||||
|
|
||||||
// Un autre device ne voit pas le job
|
// Un autre user ne voit pas le job
|
||||||
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/ocr/jobs/"+created.ID, tokenB, nil, "")
|
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/ocr/jobs/"+created.ID, tokenB, nil, "")
|
||||||
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-other-device")
|
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-other-owner")
|
||||||
}
|
}
|
||||||
|
|
||||||
func bodyFor(id string) []byte {
|
func bodyFor(id string) []byte {
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterRoutes wires the full /api/v1 surface (public + protected).
|
// RegisterRoutes wires the full /api/v1 surface (public + protected).
|
||||||
// Public: /health, /devices. Everything else requires a device bearer token.
|
// Public: /health, /devices, /auth/login. Everything else requires a user
|
||||||
|
// bearer token (subject = user_id, claim = device_id, cf. docs/api-v1.md §2).
|
||||||
func RegisterRoutes(r *gin.Engine) {
|
func RegisterRoutes(r *gin.Engine) {
|
||||||
public := r.Group("/api/v1")
|
public := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
public.GET("/health", Health)
|
public.GET("/health", Health)
|
||||||
public.POST("/devices", DevicesRegister)
|
public.POST("/devices", DevicesRegister)
|
||||||
|
public.POST("/auth/login", AuthLogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected := r.Group("/api/v1")
|
protected := r.Group("/api/v1")
|
||||||
protected.Use(RequireDevice)
|
protected.Use(RequireAuth)
|
||||||
{
|
{
|
||||||
protected.GET("/files", FilesList)
|
protected.GET("/files", FilesList)
|
||||||
protected.GET("/files/search", FilesSearch)
|
protected.GET("/files/search", FilesSearch)
|
||||||
@@ -23,6 +27,9 @@ func RegisterRoutes(r *gin.Engine) {
|
|||||||
protected.GET("/files/folders", FoldersList)
|
protected.GET("/files/folders", FoldersList)
|
||||||
protected.POST("/files/upload", FilesUpload)
|
protected.POST("/files/upload", FilesUpload)
|
||||||
|
|
||||||
|
protected.GET("/users/resolve", ResolveUser)
|
||||||
|
protected.PATCH("/users/me/password", ChangePassword)
|
||||||
|
|
||||||
protected.POST("/ocr/jobs", OcrJobsCreate)
|
protected.POST("/ocr/jobs", OcrJobsCreate)
|
||||||
protected.GET("/ocr/jobs/:id", OcrJobsGet)
|
protected.GET("/ocr/jobs/:id", OcrJobsGet)
|
||||||
|
|
||||||
@@ -30,3 +37,10 @@ func RegisterRoutes(r *gin.Engine) {
|
|||||||
protected.GET("/sync/permissions", SyncPermissionsGet)
|
protected.GET("/sync/permissions", SyncPermissionsGet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userID lit la clé de scoping posée par RequireAuth. Un empty string est
|
||||||
|
// impossible en conditions normales (le middleware l'a validé) ; garde-fou
|
||||||
|
// pour un appel direct dans les tests.
|
||||||
|
func userID(c *gin.Context) string {
|
||||||
|
return strings.TrimSpace(c.GetString(UserIDKey))
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func SyncOpsPush(c *gin.Context) {
|
|||||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceID := c.GetString(DeviceIDKey)
|
userID := c.GetString(UserIDKey)
|
||||||
|
|
||||||
var req syncOpsRequest
|
var req syncOpsRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -26,7 +26,7 @@ func SyncOpsPush(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := Store.ApplyBatch(deviceID, req.Operations)
|
result, err := Store.ApplyBatch(userID, c.GetString(DeviceIDKey), req.Operations)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@@ -39,13 +39,13 @@ func SyncPermissionsGet(c *gin.Context) {
|
|||||||
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceID := c.GetString(DeviceIDKey)
|
userID := c.GetString(UserIDKey)
|
||||||
after := c.Query("after")
|
after := c.Query("after")
|
||||||
var afterMs int64
|
var afterMs int64
|
||||||
if after != "" {
|
if after != "" {
|
||||||
afterMs = int64(intParam(after, 0))
|
afterMs = int64(intParam(after, 0))
|
||||||
}
|
}
|
||||||
perms, err := Store.Snapshot(deviceID, afterMs)
|
perms, err := Store.Snapshot(userID, afterMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ func op(operationID int64, resourceID, operation, resourceType string, payload m
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSyncOpsApplySequential(t *testing.T) {
|
func TestSyncOpsApplySequential(t *testing.T) {
|
||||||
r, _, _ := setup(t)
|
r, _, repo := setup(t)
|
||||||
device := repository.NewID()
|
device := repository.NewID()
|
||||||
token := registerDevice(t, r, device)
|
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "sa"), "sync-test-password", device)
|
||||||
|
|
||||||
folderID := repository.NewID()
|
folderID := repository.NewID()
|
||||||
fileID := repository.NewID()
|
fileID := repository.NewID()
|
||||||
@@ -78,9 +78,9 @@ func TestSyncOpsApplySequential(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) {
|
func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) {
|
||||||
r, _, _ := setup(t)
|
r, _, repo := setup(t)
|
||||||
device := repository.NewID()
|
device := repository.NewID()
|
||||||
token := registerDevice(t, r, device)
|
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "sb"), "sync-test-password", device)
|
||||||
|
|
||||||
folderID := repository.NewID()
|
folderID := repository.NewID()
|
||||||
dupeID := repository.NewID()
|
dupeID := repository.NewID()
|
||||||
@@ -126,10 +126,10 @@ func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) {
|
|||||||
func TestSyncOpsDeleteIdempotent(t *testing.T) {
|
func TestSyncOpsDeleteIdempotent(t *testing.T) {
|
||||||
r, _, repo := setup(t)
|
r, _, repo := setup(t)
|
||||||
device := repository.NewID()
|
device := repository.NewID()
|
||||||
token := registerDevice(t, r, device)
|
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sc"), "sync-test-password", device)
|
||||||
|
|
||||||
fileID := repository.NewID()
|
fileID := repository.NewID()
|
||||||
if err := repo.Resources.InsertFile(device, fileID, "x.txt", "", 1, nil, nil); err != nil {
|
if err := repo.Resources.InsertFile(user, fileID, "x.txt", "", 1, nil, nil); err != nil {
|
||||||
t.Fatalf("insert: %v", err)
|
t.Fatalf("insert: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,10 +155,10 @@ func TestSyncOpsDeleteIdempotent(t *testing.T) {
|
|||||||
func TestSnapshotPermissions(t *testing.T) {
|
func TestSnapshotPermissions(t *testing.T) {
|
||||||
r, _, repo := setup(t)
|
r, _, repo := setup(t)
|
||||||
device := repository.NewID()
|
device := repository.NewID()
|
||||||
token := registerDevice(t, r, device)
|
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sd"), "sync-test-password", device)
|
||||||
|
|
||||||
folderID := repository.NewID()
|
folderID := repository.NewID()
|
||||||
if err := repo.Resources.InsertFolder(device, folderID, "Docs", ""); err != nil {
|
if err := repo.Resources.InsertFolder(user, folderID, "Docs", ""); err != nil {
|
||||||
t.Fatalf("insert folder: %v", err)
|
t.Fatalf("insert folder: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ func TestSnapshotPermissions(t *testing.T) {
|
|||||||
if err := json.Unmarshal(env.Data, &perms); err != nil {
|
if err := json.Unmarshal(env.Data, &perms); err != nil {
|
||||||
t.Fatalf("snapshot: unmarshal: %v", err)
|
t.Fatalf("snapshot: unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
if len(perms) != 1 || perms[0].ResourceID != folderID || perms[0].EffectiveAccess != "owner" || perms[0].OwnerID != device {
|
if len(perms) != 1 || perms[0].ResourceID != folderID || perms[0].EffectiveAccess != "owner" || perms[0].OwnerID != user {
|
||||||
t.Errorf("snapshot: %+v", perms)
|
t.Errorf("snapshot: %+v", perms)
|
||||||
}
|
}
|
||||||
if perms[0].CachedAt == 0 {
|
if perms[0].CachedAt == 0 {
|
||||||
|
|||||||
+29
-10
@@ -8,11 +8,22 @@ import (
|
|||||||
"aidanwoods.dev/go-paseto"
|
"aidanwoods.dev/go-paseto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TokenTTL = 90 * 24 * time.Hour
|
// TokenTTL = 7 jours, sans refresh (V1). À expiration, le client doit
|
||||||
|
// re-demander un POST /auth/login.
|
||||||
|
const TokenTTL = 7 * 24 * time.Hour
|
||||||
|
|
||||||
var ErrInvalidToken = errors.New("invalid token")
|
var ErrInvalidToken = errors.New("invalid token")
|
||||||
|
|
||||||
// Manager issues and verifies paseto v4-local bearer tokens bound to a device_id.
|
// Identity est le résultat de Verify : l'utilisateur (sujet du token, autorisant)
|
||||||
|
// et le device (claim secondaire, porté mais NON autorisant seul).
|
||||||
|
type Identity struct {
|
||||||
|
UserID string
|
||||||
|
DeviceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager issues and verifies paseto v4-local bearer tokens.
|
||||||
|
// Le subject est l'USER (scope des ressources) ; device_id est un claim
|
||||||
|
// transporté pour l'idempotence outbox et le nommage des uploads.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
key paseto.V4SymmetricKey
|
key paseto.V4SymmetricKey
|
||||||
}
|
}
|
||||||
@@ -26,24 +37,32 @@ func NewManager(secret string) (*Manager, error) {
|
|||||||
return &Manager{key: key}, nil
|
return &Manager{key: key}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Issue(deviceID string) (string, error) {
|
// Issue émet un token lié à un utilisateur ET à un device.
|
||||||
|
func (m *Manager) Issue(userID, deviceID string) (string, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
token := paseto.NewToken()
|
token := paseto.NewToken()
|
||||||
token.SetIssuedAt(now)
|
token.SetIssuedAt(now)
|
||||||
token.SetNotBefore(now)
|
token.SetNotBefore(now)
|
||||||
token.SetExpiration(now.Add(TokenTTL))
|
token.SetExpiration(now.Add(TokenTTL))
|
||||||
token.SetSubject(deviceID)
|
token.SetSubject(userID)
|
||||||
|
// v1.6.0 : SetString n'expose pas d'erreur (panic si non sérialisable).
|
||||||
|
token.SetString("device_id", deviceID)
|
||||||
return token.V4Encrypt(m.key, nil), nil
|
return token.V4Encrypt(m.key, nil), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Verify(signed string) (string, error) {
|
// Verify décode et contrôle le token, retourne l'identité (user + device).
|
||||||
|
func (m *Manager) Verify(signed string) (Identity, error) {
|
||||||
parsed, err := paseto.NewParserForValidNow().ParseV4Local(m.key, signed, nil)
|
parsed, err := paseto.NewParserForValidNow().ParseV4Local(m.key, signed, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", ErrInvalidToken
|
return Identity{}, ErrInvalidToken
|
||||||
}
|
}
|
||||||
subject, err := parsed.GetSubject()
|
userID, err := parsed.GetSubject()
|
||||||
if err != nil {
|
if err != nil || userID == "" {
|
||||||
return "", ErrInvalidToken
|
return Identity{}, ErrInvalidToken
|
||||||
}
|
}
|
||||||
return subject, nil
|
deviceID, err := parsed.GetString("device_id")
|
||||||
|
if err != nil || deviceID == "" {
|
||||||
|
return Identity{}, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return Identity{UserID: userID, DeviceID: deviceID}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"aidanwoods.dev/go-paseto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testUserID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
testDeviceID = "0123456789abcdef0123456789abcdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIssueVerifyRoundtrip(t *testing.T) {
|
func TestIssueVerifyRoundtrip(t *testing.T) {
|
||||||
@@ -11,8 +19,7 @@ func TestIssueVerifyRoundtrip(t *testing.T) {
|
|||||||
t.Fatalf("NewManager: %v", err)
|
t.Fatalf("NewManager: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceID := "0123456789abcdef0123456789abcdef"
|
signed, err := m.Issue(testUserID, testDeviceID)
|
||||||
signed, err := m.Issue(deviceID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Issue: %v", err)
|
t.Fatalf("Issue: %v", err)
|
||||||
}
|
}
|
||||||
@@ -21,18 +28,33 @@ func TestIssueVerifyRoundtrip(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Verify: %v", err)
|
t.Fatalf("Verify: %v", err)
|
||||||
}
|
}
|
||||||
if got != deviceID {
|
if got.UserID != testUserID {
|
||||||
t.Fatalf("Verify: got %q want %q", got, deviceID)
|
t.Errorf("Verify.UserID = %q, want %q", got.UserID, testUserID)
|
||||||
|
}
|
||||||
|
if got.DeviceID != testDeviceID {
|
||||||
|
t.Errorf("Verify.DeviceID = %q, want %q", got.DeviceID, testDeviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueIsUserSubject(t *testing.T) {
|
||||||
|
m, _ := NewManager("test-secret")
|
||||||
|
signed, err := m.Issue(testUserID, testDeviceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
parsed, err := paseto.NewParserForValidNow().ParseV4Local(m.key, signed, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if subject, _ := parsed.GetSubject(); subject != testUserID {
|
||||||
|
t.Errorf("subject = %q, want user %q", subject, testUserID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVerifyRejectsTamperedToken(t *testing.T) {
|
func TestVerifyRejectsTamperedToken(t *testing.T) {
|
||||||
m, err := NewManager("test-secret")
|
m, _ := NewManager("test-secret")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewManager: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
signed, _ := m.Issue("0123456789abcdef0123456789abcdef")
|
signed, _ := m.Issue(testUserID, testDeviceID)
|
||||||
parts := strings.Split(signed, ".")
|
parts := strings.Split(signed, ".")
|
||||||
parts[len(parts)-1] = "nope"
|
parts[len(parts)-1] = "nope"
|
||||||
tampered := strings.Join(parts, ".")
|
tampered := strings.Join(parts, ".")
|
||||||
@@ -43,10 +65,7 @@ func TestVerifyRejectsTamperedToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestVerifyRejectsGarbage(t *testing.T) {
|
func TestVerifyRejectsGarbage(t *testing.T) {
|
||||||
m, err := NewManager("test-secret")
|
m, _ := NewManager("test-secret")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewManager: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := m.Verify("not-a-token"); err == nil {
|
if _, err := m.Verify("not-a-token"); err == nil {
|
||||||
t.Fatal("expected garbage to be rejected")
|
t.Fatal("expected garbage to be rejected")
|
||||||
}
|
}
|
||||||
@@ -56,8 +75,23 @@ func TestDifferentSecretRejectsToken(t *testing.T) {
|
|||||||
a, _ := NewManager("secret-a")
|
a, _ := NewManager("secret-a")
|
||||||
b, _ := NewManager("secret-b")
|
b, _ := NewManager("secret-b")
|
||||||
|
|
||||||
signed, _ := a.Issue("0123456789abcdef0123456789abcdef")
|
signed, _ := a.Issue(testUserID, testDeviceID)
|
||||||
if _, err := b.Verify(signed); err == nil {
|
if _, err := b.Verify(signed); err == nil {
|
||||||
t.Fatal("expected token from another manager to be rejected")
|
t.Fatal("expected token from another manager to be rejected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVerifyRequiresDeviceClaim(t *testing.T) {
|
||||||
|
m, _ := NewManager("test-secret")
|
||||||
|
// Token sans claim device_id (subject seul, type de l'ancien format) → invalide.
|
||||||
|
now := time.Now()
|
||||||
|
token := paseto.NewToken()
|
||||||
|
token.SetIssuedAt(now)
|
||||||
|
token.SetNotBefore(now)
|
||||||
|
token.SetExpiration(now.Add(TokenTTL))
|
||||||
|
token.SetSubject(testUserID)
|
||||||
|
signed := token.V4Encrypt(m.key, nil)
|
||||||
|
if _, err := m.Verify(signed); err == nil {
|
||||||
|
t.Fatal("expected token without device claim to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Package passwd gère le hachage des mots de passe (argon2id).
|
||||||
|
// Paramètres explicites, jamais de défauts implicites de la lib.
|
||||||
|
// Attention : ne jamais logger ni exposer un hash hors de Verify/Hash.
|
||||||
|
package passwd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Paramètres argon2id (OWASP-ish, raisonnables pour un usage familial V1).
|
||||||
|
const (
|
||||||
|
argonTime = 1
|
||||||
|
argonMemory = 64 * 1024 // 64 MiB
|
||||||
|
argonThreads = 4
|
||||||
|
argonKeyLen = 32
|
||||||
|
argonSaltLen = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrMismatch : mot de passe (ou dummy) ne correspond pas au hash.
|
||||||
|
ErrMismatch = errors.New("invalid password")
|
||||||
|
// ErrMalformed : hash stocké illisible/corrompu dans la base.
|
||||||
|
ErrMalformed = errors.New("malformed password hash")
|
||||||
|
)
|
||||||
|
|
||||||
|
// dummyHash est un hash argon2id valide d'un mot de passe aléatoire fixe.
|
||||||
|
// Il sert d'égalisation de timing : quand le username est inconnu, on vérifie
|
||||||
|
// quand même contre ce hash pour que le délai de réponse soit identique à un
|
||||||
|
// « mauvais mot de passe ».
|
||||||
|
var dummyHash = func() string {
|
||||||
|
h, err := Hash("vaultdrop-constantime-dummy")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("passwd: impossible de pré-hacher le dummy: %v", err))
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Hash produit une chaîne encodable : $argon2id$v=19$m=65536,t=1,p=4$<salt>$<hash>.
|
||||||
|
func Hash(password string) (string, error) {
|
||||||
|
salt := make([]byte, argonSaltLen)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("passwd: rand: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
|
|
||||||
|
params := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d",
|
||||||
|
argon2.Version, argonMemory, argonTime, argonThreads)
|
||||||
|
saltEnc := base64.RawStdEncoding.EncodeToString(salt)
|
||||||
|
keyEnc := base64.RawStdEncoding.EncodeToString(key)
|
||||||
|
return params + "$" + saltEnc + "$" + keyEnc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify compare le mot de passe au hash stocké (temps constant sur le hash).
|
||||||
|
// En cas de hash corrompu, on vérifie contre le dummyHash (même délai).
|
||||||
|
func Verify(password, stored string) error {
|
||||||
|
fields := strings.Split(stored, "$")
|
||||||
|
if len(fields) != 6 || fields[1] != "argon2id" {
|
||||||
|
return ErrMalformed
|
||||||
|
}
|
||||||
|
|
||||||
|
var version int
|
||||||
|
if _, err := fmt.Sscanf(fields[2], "v=%d", &version); err != nil || version != argon2.Version {
|
||||||
|
return ErrMalformed
|
||||||
|
}
|
||||||
|
|
||||||
|
var memory, timeCost, threads int
|
||||||
|
if _, err := fmt.Sscanf(fields[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
|
||||||
|
return ErrMalformed
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(fields[4])
|
||||||
|
if err != nil {
|
||||||
|
return ErrMalformed
|
||||||
|
}
|
||||||
|
want, err := base64.RawStdEncoding.DecodeString(fields[5])
|
||||||
|
if err != nil {
|
||||||
|
return ErrMalformed
|
||||||
|
}
|
||||||
|
|
||||||
|
got := argon2.IDKey([]byte(password), salt, uint32(timeCost), uint32(memory), uint8(threads), uint32(len(want)))
|
||||||
|
if subtle.ConstantTimeCompare(got, want) != 1 {
|
||||||
|
return ErrMismatch
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyTimedEqual vérifie le mot de passe fourni en égalisant le temps de
|
||||||
|
// réponse que le compte existe ou non : username inconnu → vérification contre
|
||||||
|
// dummyHash (retourne toujours ErrMismatch, dans un délai comparable).
|
||||||
|
func VerifyTimedEqual(password, stored string) error {
|
||||||
|
target := stored
|
||||||
|
if stored == "" {
|
||||||
|
target = dummyHash
|
||||||
|
}
|
||||||
|
return Verify(password, target)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package passwd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHashVerifyRoundTrip(t *testing.T) {
|
||||||
|
hash, err := Hash("mon-super-mot-de-passe")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Hash: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(hash, "$argon2id$v=19$m=65536,t=1,p=4$") {
|
||||||
|
t.Errorf("format inattendu : %s", hash)
|
||||||
|
}
|
||||||
|
if err := Verify("mon-super-mot-de-passe", hash); err != nil {
|
||||||
|
t.Errorf("Verify (bon mot de passe) = %v", err)
|
||||||
|
}
|
||||||
|
if err := Verify("mauvais", hash); err != ErrMismatch {
|
||||||
|
t.Errorf("Verify (mauvais mot de passe) = %v, attendu ErrMismatch", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyTimedEqualUnknownUsername(t *testing.T) {
|
||||||
|
// username inconnu → stored vide → vérification contre dummyHash.
|
||||||
|
// Ne doit JAMAIS réussir, et doit retourner ErrMismatch proprement.
|
||||||
|
if err := VerifyTimedEqual("whatever", ""); err != ErrMismatch {
|
||||||
|
t.Errorf("VerifyTimedEqual(stored vide) = %v, attendu ErrMismatch", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyMalformed(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"",
|
||||||
|
"pas-un-hash",
|
||||||
|
"$argon2id$v=19$m=65536,t=1,p=4$AAAA",
|
||||||
|
"$argon2id$v=18$m=65536,t=1,p=4$c2FsdA==$a2V5",
|
||||||
|
}
|
||||||
|
for _, stored := range cases {
|
||||||
|
if err := Verify("x", stored); err != ErrMalformed {
|
||||||
|
t.Errorf("Verify(%q) = %v, attendu ErrMalformed", stored, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashIsSaltRandomized(t *testing.T) {
|
||||||
|
a, _ := Hash("same")
|
||||||
|
b, _ := Hash("same")
|
||||||
|
if a == b {
|
||||||
|
t.Error("deux hashes du même mot de passe identiques (salt non randomisé ?)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashNeverExposesPassword(t *testing.T) {
|
||||||
|
hash, err := Hash("secret-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Hash: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(hash, "secret-password") {
|
||||||
|
t.Error("le hash contient le mot de passe en clair")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Devices persists registered devices (owner scope for every resource row).
|
// Devices persists registered devices (idempotence outbox par device, dernier
|
||||||
|
// user_id informatif).
|
||||||
type Devices struct {
|
type Devices struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
}
|
}
|
||||||
@@ -29,3 +30,13 @@ func (d *Devices) Exists(deviceID string) (bool, error) {
|
|||||||
}
|
}
|
||||||
return err == nil, err
|
return err == nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkUser mémorise le dernier utilisateur connecté sur ce device (INFORMATIF,
|
||||||
|
// jamais autorisant) et rafraîchit last_seen_at. Appelé au login.
|
||||||
|
func (d *Devices) MarkUser(deviceID, userID string) error {
|
||||||
|
_, err := d.DB.Exec(
|
||||||
|
`UPDATE devices SET user_id = $2, last_seen_at = NOW() WHERE device_id = $1`,
|
||||||
|
deviceID, userID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Repository struct {
|
|||||||
Devices *Devices
|
Devices *Devices
|
||||||
Operations *Operations
|
Operations *Operations
|
||||||
OcrJobs *OcrJobs
|
OcrJobs *OcrJobs
|
||||||
|
Users *Users
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepository(conn *sql.DB) *Repository {
|
func NewRepository(conn *sql.DB) *Repository {
|
||||||
@@ -18,5 +19,6 @@ func NewRepository(conn *sql.DB) *Repository {
|
|||||||
Devices: &Devices{DB: conn},
|
Devices: &Devices{DB: conn},
|
||||||
Operations: &Operations{DB: conn},
|
Operations: &Operations{DB: conn},
|
||||||
OcrJobs: &OcrJobs{DB: conn},
|
OcrJobs: &OcrJobs{DB: conn},
|
||||||
|
Users: &Users{DB: conn},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func (r *Resources) folderExists(ownerID, folderID string) (bool, error) {
|
|||||||
var exists int
|
var exists int
|
||||||
err := r.DB.QueryRow(
|
err := r.DB.QueryRow(
|
||||||
`SELECT 1 FROM resources
|
`SELECT 1 FROM resources
|
||||||
WHERE resource_id = $1 AND owner_id = $2 AND type = 'folder' AND deleted_at IS NULL`,
|
WHERE resource_id = $1 AND user_id = $2 AND type = 'folder' AND deleted_at IS NULL`,
|
||||||
folderID, ownerID,
|
folderID, ownerID,
|
||||||
).Scan(&exists)
|
).Scan(&exists)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -102,7 +102,7 @@ func (r *Resources) insert(ownerID, id, name, parentResourceID, resourceType str
|
|||||||
parentID = parentResourceID
|
parentID = parentResourceID
|
||||||
}
|
}
|
||||||
_, err := r.DB.Exec(
|
_, err := r.DB.Exec(
|
||||||
`INSERT INTO resources (resource_id, type, name, parent_id, owner_id, size_bytes, mime_type, extension)
|
`INSERT INTO resources (resource_id, type, name, parent_id, user_id, size_bytes, mime_type, extension)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
id, resourceType, name, parentID, ownerID, size, mimeType, extension,
|
id, resourceType, name, parentID, ownerID, size, mimeType, extension,
|
||||||
)
|
)
|
||||||
@@ -135,7 +135,7 @@ func (r *Resources) scanFile(scan func(...any) error) (FileRow, error) {
|
|||||||
// root when folderResourceID is empty), plus the total count.
|
// root when folderResourceID is empty), plus the total count.
|
||||||
func (r *Resources) ListFiles(ownerID, folderResourceID string, limit, offset int, sort, order string) ([]FileRow, int, error) {
|
func (r *Resources) ListFiles(ownerID, folderResourceID string, limit, offset int, sort, order string) ([]FileRow, int, error) {
|
||||||
column, direction := sortClause(sort, order)
|
column, direction := sortClause(sort, order)
|
||||||
where := `type = 'file' AND deleted_at IS NULL AND owner_id = $1 AND ($2::text = '' AND parent_id IS NULL OR parent_id = $2)`
|
where := `type = 'file' AND deleted_at IS NULL AND user_id = $1 AND ($2::text = '' AND parent_id IS NULL OR parent_id = $2)`
|
||||||
|
|
||||||
var total int
|
var total int
|
||||||
if err := r.DB.QueryRow(`SELECT COUNT(*) FROM resources WHERE `+where, ownerID, folderResourceID).Scan(&total); err != nil {
|
if err := r.DB.QueryRow(`SELECT COUNT(*) FROM resources WHERE `+where, ownerID, folderResourceID).Scan(&total); err != nil {
|
||||||
@@ -164,7 +164,7 @@ func (r *Resources) ListFiles(ownerID, folderResourceID string, limit, offset in
|
|||||||
func (r *Resources) GetFile(ownerID, resourceID string) (FileRow, error) {
|
func (r *Resources) GetFile(ownerID, resourceID string) (FileRow, error) {
|
||||||
row := r.DB.QueryRow(
|
row := r.DB.QueryRow(
|
||||||
`SELECT `+fileColumns+` FROM resources
|
`SELECT `+fileColumns+` FROM resources
|
||||||
WHERE type = 'file' AND deleted_at IS NULL AND owner_id = $1 AND resource_id = $2`,
|
WHERE type = 'file' AND deleted_at IS NULL AND user_id = $1 AND resource_id = $2`,
|
||||||
ownerID, resourceID,
|
ownerID, resourceID,
|
||||||
)
|
)
|
||||||
file, err := r.scanFile(row.Scan)
|
file, err := r.scanFile(row.Scan)
|
||||||
@@ -178,7 +178,7 @@ func (r *Resources) GetFile(ownerID, resourceID string) (FileRow, error) {
|
|||||||
func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) {
|
func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) {
|
||||||
result, err := r.DB.Exec(
|
result, err := r.DB.Exec(
|
||||||
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
|
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
|
||||||
WHERE type = 'file' AND deleted_at IS NULL AND owner_id = $1 AND resource_id = $2`,
|
WHERE type = 'file' AND deleted_at IS NULL AND user_id = $1 AND resource_id = $2`,
|
||||||
ownerID, resourceID,
|
ownerID, resourceID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -198,7 +198,7 @@ func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) {
|
|||||||
// (case-insensitive substring, wildcards escaped), plus the total count.
|
// (case-insensitive substring, wildcards escaped), plus the total count.
|
||||||
func (r *Resources) SearchFiles(ownerID, q string, limit, offset int) ([]FileRow, int, error) {
|
func (r *Resources) SearchFiles(ownerID, q string, limit, offset int) ([]FileRow, int, error) {
|
||||||
pattern := `%` + escapeLike(q) + `%`
|
pattern := `%` + escapeLike(q) + `%`
|
||||||
where := `type = 'file' AND deleted_at IS NULL AND owner_id = $1 AND name ILIKE $2 ESCAPE '\'`
|
where := `type = 'file' AND deleted_at IS NULL AND user_id = $1 AND name ILIKE $2 ESCAPE '\'`
|
||||||
|
|
||||||
var total int
|
var total int
|
||||||
if err := r.DB.QueryRow(`SELECT COUNT(*) FROM resources WHERE `+where, ownerID, pattern).Scan(&total); err != nil {
|
if err := r.DB.QueryRow(`SELECT COUNT(*) FROM resources WHERE `+where, ownerID, pattern).Scan(&total); err != nil {
|
||||||
@@ -234,7 +234,7 @@ func (r *Resources) GetFolder(ownerID, resourceID string) (FolderRow, error) {
|
|||||||
var row FolderRow
|
var row FolderRow
|
||||||
err := r.DB.QueryRow(
|
err := r.DB.QueryRow(
|
||||||
`SELECT resource_id, name, COALESCE(parent_id, '') FROM resources
|
`SELECT resource_id, name, COALESCE(parent_id, '') FROM resources
|
||||||
WHERE type = 'folder' AND deleted_at IS NULL AND owner_id = $1 AND resource_id = $2`,
|
WHERE type = 'folder' AND deleted_at IS NULL AND user_id = $1 AND resource_id = $2`,
|
||||||
ownerID, resourceID,
|
ownerID, resourceID,
|
||||||
).Scan(&row.ID, &row.Name, &row.ParentID)
|
).Scan(&row.ID, &row.Name, &row.ParentID)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -257,7 +257,7 @@ func (r *Resources) MoveResource(ownerID, resourceID, parentResourceID string) e
|
|||||||
}
|
}
|
||||||
result, err := r.DB.Exec(
|
result, err := r.DB.Exec(
|
||||||
`UPDATE resources SET parent_id = $3, updated_at = NOW()
|
`UPDATE resources SET parent_id = $3, updated_at = NOW()
|
||||||
WHERE resource_id = $1 AND owner_id = $2 AND deleted_at IS NULL`,
|
WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
||||||
resourceID, ownerID, parentID,
|
resourceID, ownerID, parentID,
|
||||||
)
|
)
|
||||||
if err != nil && isUniqueViolation(err) {
|
if err != nil && isUniqueViolation(err) {
|
||||||
@@ -280,7 +280,7 @@ func (r *Resources) MoveResource(ownerID, resourceID, parentResourceID string) e
|
|||||||
func (r *Resources) UpdateName(ownerID, resourceID, name string) error {
|
func (r *Resources) UpdateName(ownerID, resourceID, name string) error {
|
||||||
result, err := r.DB.Exec(
|
result, err := r.DB.Exec(
|
||||||
`UPDATE resources SET name = $3, updated_at = NOW()
|
`UPDATE resources SET name = $3, updated_at = NOW()
|
||||||
WHERE resource_id = $1 AND owner_id = $2 AND deleted_at IS NULL`,
|
WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
||||||
resourceID, ownerID, name,
|
resourceID, ownerID, name,
|
||||||
)
|
)
|
||||||
if err != nil && isUniqueViolation(err) {
|
if err != nil && isUniqueViolation(err) {
|
||||||
@@ -304,7 +304,7 @@ func (r *Resources) UpdateName(ownerID, resourceID, name string) error {
|
|||||||
func (r *Resources) SyncDelete(ownerID, resourceID string) error {
|
func (r *Resources) SyncDelete(ownerID, resourceID string) error {
|
||||||
_, err := r.DB.Exec(
|
_, err := r.DB.Exec(
|
||||||
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
|
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
|
||||||
WHERE resource_id = $1 AND owner_id = $2 AND deleted_at IS NULL`,
|
WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
||||||
ownerID, resourceID,
|
ownerID, resourceID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@@ -314,7 +314,7 @@ func (r *Resources) SyncDelete(ownerID, resourceID string) error {
|
|||||||
func (r *Resources) ExistsOwner(ownerID, resourceID string) (bool, error) {
|
func (r *Resources) ExistsOwner(ownerID, resourceID string) (bool, error) {
|
||||||
var exists int
|
var exists int
|
||||||
err := r.DB.QueryRow(
|
err := r.DB.QueryRow(
|
||||||
`SELECT 1 FROM resources WHERE resource_id = $1 AND owner_id = $2 AND deleted_at IS NULL`,
|
`SELECT 1 FROM resources WHERE resource_id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
||||||
resourceID, ownerID,
|
resourceID, ownerID,
|
||||||
).Scan(&exists)
|
).Scan(&exists)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -335,7 +335,7 @@ type OwnedRow struct {
|
|||||||
func (r *Resources) ListOwned(ownerID string, afterMs int64) ([]OwnedRow, error) {
|
func (r *Resources) ListOwned(ownerID string, afterMs int64) ([]OwnedRow, error) {
|
||||||
rows, err := r.DB.Query(
|
rows, err := r.DB.Query(
|
||||||
`SELECT resource_id, type, updated_at FROM resources
|
`SELECT resource_id, type, updated_at FROM resources
|
||||||
WHERE owner_id = $1 AND deleted_at IS NULL
|
WHERE user_id = $1 AND deleted_at IS NULL
|
||||||
AND (EXTRACT(EPOCH FROM updated_at) * 1000)::bigint > $2
|
AND (EXTRACT(EPOCH FROM updated_at) * 1000)::bigint > $2
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT 10000`,
|
LIMIT 10000`,
|
||||||
@@ -360,7 +360,7 @@ func (r *Resources) ListOwned(ownerID string, afterMs int64) ([]OwnedRow, error)
|
|||||||
func (r *Resources) ListRootFolders(ownerID string) ([]FolderRow, error) {
|
func (r *Resources) ListRootFolders(ownerID string) ([]FolderRow, error) {
|
||||||
rows, err := r.DB.Query(
|
rows, err := r.DB.Query(
|
||||||
`SELECT resource_id, name, COALESCE(parent_id, '') FROM resources
|
`SELECT resource_id, name, COALESCE(parent_id, '') FROM resources
|
||||||
WHERE type = 'folder' AND parent_id IS NULL AND deleted_at IS NULL AND owner_id = $1
|
WHERE type = 'folder' AND parent_id IS NULL AND deleted_at IS NULL AND user_id = $1
|
||||||
ORDER BY name ASC`,
|
ORDER BY name ASC`,
|
||||||
ownerID,
|
ownerID,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ func newTestResources(t *testing.T) *Resources {
|
|||||||
return &Resources{DB: conn}
|
return &Resources{DB: conn}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustInsertDevice(t *testing.T, repo *Resources, deviceID string) {
|
func mustInsertUser(t *testing.T, repo *Resources, userID string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dev := &Devices{DB: repo.DB}
|
users := &Users{DB: repo.DB}
|
||||||
if err := dev.Upsert(deviceID); err != nil {
|
username := "user-" + userID[:8]
|
||||||
t.Fatalf("upsert device: %v", err)
|
if _, err := users.DB.Exec(
|
||||||
|
`INSERT INTO users (id, username, username_normalized, password_hash, is_admin, created_at)
|
||||||
|
VALUES ($1, $2, $3, 'test-hash', false, NOW())`,
|
||||||
|
userID, username, username,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +32,8 @@ func TestCRUDScopedByOwner(t *testing.T) {
|
|||||||
repo := newTestResources(t)
|
repo := newTestResources(t)
|
||||||
owner := NewID()
|
owner := NewID()
|
||||||
other := NewID()
|
other := NewID()
|
||||||
mustInsertDevice(t, repo, owner)
|
mustInsertUser(t, repo, owner)
|
||||||
mustInsertDevice(t, repo, other)
|
mustInsertUser(t, repo, other)
|
||||||
|
|
||||||
folderID := NewID()
|
folderID := NewID()
|
||||||
if err := repo.InsertFolder(owner, folderID, "Docs", ""); err != nil {
|
if err := repo.InsertFolder(owner, folderID, "Docs", ""); err != nil {
|
||||||
@@ -67,7 +72,7 @@ func TestCRUDScopedByOwner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := repo.GetFile(other, fileID); err != ErrNotFound {
|
if _, err := repo.GetFile(other, fileID); err != ErrNotFound {
|
||||||
t.Errorf("autre device doit voir NOT_FOUND, got %v", err)
|
t.Errorf("autre user doit voir NOT_FOUND, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deleted, err := repo.DeleteFile(owner, fileID)
|
deleted, err := repo.DeleteFile(owner, fileID)
|
||||||
@@ -82,7 +87,7 @@ func TestCRUDScopedByOwner(t *testing.T) {
|
|||||||
func TestNameConflictAndUnknownFolder(t *testing.T) {
|
func TestNameConflictAndUnknownFolder(t *testing.T) {
|
||||||
repo := newTestResources(t)
|
repo := newTestResources(t)
|
||||||
owner := NewID()
|
owner := NewID()
|
||||||
mustInsertDevice(t, repo, owner)
|
mustInsertUser(t, repo, owner)
|
||||||
|
|
||||||
folderID := NewID()
|
folderID := NewID()
|
||||||
if err := repo.InsertFolder(owner, folderID, "Docs", ""); err != nil {
|
if err := repo.InsertFolder(owner, folderID, "Docs", ""); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserRow est une ligne users (identité locale, pas le DTO du contrat).
|
||||||
|
type UserRow struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
UsernameNormalized string
|
||||||
|
PasswordHash string
|
||||||
|
IsAdmin bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users persiste les comptes utilisateurs (auth par login, cf. tranche identité).
|
||||||
|
type Users struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count retourne le nombre d'utilisateurs (y compris supprimés) — sert au
|
||||||
|
// bootstrap admin au démarrage.
|
||||||
|
func (u *Users) Count() (int, error) {
|
||||||
|
var n int
|
||||||
|
err := u.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUsernameNormalized cherche le compte actif par username normalisé
|
||||||
|
// (lowercase, unique). Absent → ErrNotFound.
|
||||||
|
func (u *Users) GetByUsernameNormalized(normalized string) (*UserRow, error) {
|
||||||
|
row := u.DB.QueryRow(
|
||||||
|
`SELECT id, username, username_normalized, password_hash, is_admin, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE username_normalized = $1 AND deleted_at IS NULL`,
|
||||||
|
normalized,
|
||||||
|
)
|
||||||
|
user, err := scanUser(row.Scan)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID charge un compte actif par id. Absent → ErrNotFound.
|
||||||
|
func (u *Users) GetByID(id string) (*UserRow, error) {
|
||||||
|
row := u.DB.QueryRow(
|
||||||
|
`SELECT id, username, username_normalized, password_hash, is_admin, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
user, err := scanUser(row.Scan)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveExact est l'unique résolution de destinataire (aucun listing, aucun
|
||||||
|
// préfixe) : {id, username} pour le username normalisé, ou ErrNotFound.
|
||||||
|
func (u *Users) ResolveExact(normalized string) (*UserRow, error) {
|
||||||
|
return u.GetByUsernameNormalized(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create insère un compte avec un id 32-hex généré. Retourne l'id.
|
||||||
|
func (u *Users) Create(username, usernameNormalized, passwordHash string, isAdmin bool) (string, error) {
|
||||||
|
id := NewID()
|
||||||
|
_, err := u.DB.Exec(
|
||||||
|
`INSERT INTO users (id, username, username_normalized, password_hash, is_admin, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
||||||
|
id, username, usernameNormalized, passwordHash, isAdmin,
|
||||||
|
)
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return "", ErrNameConflict
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePassword remplace le hash du compte. Les tokens déjà émis restent
|
||||||
|
// valides 7 jours : limite assumée V1 (pas de liste de révocation).
|
||||||
|
func (u *Users) UpdatePassword(id, newHash string) error {
|
||||||
|
_, err := u.DB.Exec(
|
||||||
|
`UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
|
id, newHash,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkDeleted soft-delete un compte : les tokens issus sont rejetés par le
|
||||||
|
// middleware (GetByID filtre deleted_at).
|
||||||
|
func (u *Users) MarkDeleted(id string) error {
|
||||||
|
_, err := u.DB.Exec(
|
||||||
|
`UPDATE users SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1`,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUser(scan func(...any) error) (*UserRow, error) {
|
||||||
|
var user UserRow
|
||||||
|
err := scan(&user.ID, &user.Username, &user.UsernameNormalized, &user.PasswordHash, &user.IsAdmin, &user.CreatedAt)
|
||||||
|
return &user, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/vaultdrop/backend/pkg/passwd"
|
||||||
|
"github.com/vaultdrop/backend/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrAdminRequired : base vide et identifiants admin absents → le serveur
|
||||||
|
// refuse de démarrer : sans compte administrateur, il serait inutilisable
|
||||||
|
// tout en paraissant sain.
|
||||||
|
var ErrAdminRequired = errors.New("ADMIN_USERNAME/ADMIN_PASSWORD requis au premier démarrage (users vide)")
|
||||||
|
|
||||||
|
// NormalizeUsername normalise un username : trim + lowercase. C'est la forme
|
||||||
|
// recherchée (username_normalized) et l'unique clé de résolution.
|
||||||
|
func NormalizeUsername(username string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(username))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAdmin crée le compte admin au premier démarrage si la table users est
|
||||||
|
// vide. Idempotent : l'env n'écrase jamais un compte existant.
|
||||||
|
func EnsureAdmin(repo *repository.Repository, username, password string) error {
|
||||||
|
count, err := repo.Users.Count()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bootstrap admin: count: %w", err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(username) == "" || password == "" {
|
||||||
|
return ErrAdminRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := passwd.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bootstrap admin: hash: %w", err)
|
||||||
|
}
|
||||||
|
normalized := NormalizeUsername(username)
|
||||||
|
if _, err := repo.Users.Create(normalized, normalized, hash, true); err != nil {
|
||||||
|
return fmt.Errorf("bootstrap admin: create: %w", err)
|
||||||
|
}
|
||||||
|
// Jamais de log du mot de passe ni du hash.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+12
-10
@@ -30,16 +30,18 @@ func NewOcr(repo *repository.Repository, uploadDir, lang string, engine ocr.Engi
|
|||||||
return &Ocr{Repository: repo, UploadDir: uploadDir, Lang: lang, Engine: engine}
|
return &Ocr{Repository: repo, UploadDir: uploadDir, Lang: lang, Engine: engine}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create validates the file, queued the job, and starts processing.
|
// Create valide la ressource (ownership par user), met le job en file et
|
||||||
func (o *Ocr) Create(deviceID, fileID string) (OcrJobDTO, error) {
|
// lance le traitement. Le job reste scopé par device (le service génère les
|
||||||
if _, err := o.Repository.Resources.GetFile(deviceID, fileID); err != nil {
|
// jobs du device courant ; l'outbox OCR n'est pas synchronisée entre devices).
|
||||||
|
func (o *Ocr) Create(userID, deviceID, fileID string) (OcrJobDTO, error) {
|
||||||
|
if _, err := o.Repository.Resources.GetFile(userID, fileID); err != nil {
|
||||||
return OcrJobDTO{}, err
|
return OcrJobDTO{}, err
|
||||||
}
|
}
|
||||||
jobID := repository.NewID()
|
jobID := repository.NewID()
|
||||||
if err := o.Repository.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
|
if err := o.Repository.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
|
||||||
return OcrJobDTO{}, err
|
return OcrJobDTO{}, err
|
||||||
}
|
}
|
||||||
go o.process(deviceID, jobID, fileID)
|
go o.process(userID, deviceID, jobID, fileID)
|
||||||
return OcrJobDTO{ID: jobID, Status: "queued"}, nil
|
return OcrJobDTO{ID: jobID, Status: "queued"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,12 +53,12 @@ func (o *Ocr) Get(deviceID, jobID string) (OcrJobDTO, error) {
|
|||||||
return toOcrJobDTO(row), nil
|
return toOcrJobDTO(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Ocr) process(deviceID, jobID, fileID string) {
|
func (o *Ocr) process(userID, deviceID, jobID, fileID string) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := o.Repository.OcrJobs.TouchProcessing(deviceID, jobID); err != nil {
|
if err := o.Repository.OcrJobs.TouchProcessing(deviceID, jobID); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
path, err := o.physicalPath(deviceID, fileID)
|
path, err := o.physicalPath(userID, fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = o.Repository.OcrJobs.Fail(deviceID, jobID, "file not readable")
|
_ = o.Repository.OcrJobs.Fail(deviceID, jobID, "file not readable")
|
||||||
return
|
return
|
||||||
@@ -69,10 +71,10 @@ func (o *Ocr) process(deviceID, jobID, fileID string) {
|
|||||||
_ = o.Repository.OcrJobs.Complete(deviceID, jobID, text)
|
_ = o.Repository.OcrJobs.Complete(deviceID, jobID, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// physicalPath resolves UPLOAD_DIR/<device_id>/<resource_id>.<ext> — the ext
|
// physicalPath résout UPLOAD_DIR/<user_id>/<resource_id>.<ext> — l'ext est
|
||||||
// is chosen at upload time, so the actual file is matched by prefix.
|
// choisi à l'upload, le fichier réel est retrouvé par préfixe.
|
||||||
func (o *Ocr) physicalPath(deviceID, fileID string) (string, error) {
|
func (o *Ocr) physicalPath(userID, fileID string) (string, error) {
|
||||||
matches, err := filepath.Glob(filepath.Join(o.UploadDir, deviceID, fileID+".*"))
|
matches, err := filepath.Glob(filepath.Join(o.UploadDir, userID, fileID+".*"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type FolderDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resources holds the business logic for files/folders list-get-delete-upload,
|
// Resources holds the business logic for files/folders list-get-delete-upload,
|
||||||
// always scoped by the requesting device.
|
// always scoped by the requesting USER (resources.user_id after 000007).
|
||||||
type Resources struct {
|
type Resources struct {
|
||||||
Repo *repository.Resources
|
Repo *repository.Resources
|
||||||
Repository *repository.Repository
|
Repository *repository.Repository
|
||||||
@@ -101,7 +101,7 @@ func (s *Resources) SearchFiles(ownerID, q string, page, pageSize int) ([]FileDT
|
|||||||
return files, total, nil
|
return files, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload persists the multipart-sourced file under UploadDir/<device> and
|
// Upload persists the multipart-sourced file under UploadDir/<user> and
|
||||||
// records its metadata, returning the FileDTO. The physical file is removed
|
// records its metadata, returning the FileDTO. The physical file is removed
|
||||||
// if metadata persistence fails (e.g. name conflict).
|
// if metadata persistence fails (e.g. name conflict).
|
||||||
func (s *Resources) Upload(ownerID string, file *multipart.FileHeader, folderID string) (FileDTO, error) {
|
func (s *Resources) Upload(ownerID string, file *multipart.FileHeader, folderID string) (FileDTO, error) {
|
||||||
|
|||||||
+11
-8
@@ -79,13 +79,16 @@ func classifySyncError(err error) (string, string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Resources) ApplyBatch(ownerID string, ops []SyncOperation) (SyncResult, error) {
|
// ApplyBatch applique les ops de l'outbox : les mutations de ressources sont
|
||||||
|
// scopées par l'UTILISATEUR (userID), l'idempotence reste par DEVICE
|
||||||
|
// (deviceID) — cf. docs/api-v1.md §6.1.
|
||||||
|
func (s *Resources) ApplyBatch(userID, deviceID string, ops []SyncOperation) (SyncResult, error) {
|
||||||
for i := range ops {
|
for i := range ops {
|
||||||
op := &ops[i]
|
op := &ops[i]
|
||||||
if err := validateSyncOp(op); err != nil {
|
if err := validateSyncOp(op); err != nil {
|
||||||
return SyncResult{Applied: i, Failed: &FailedOperation{OperationID: op.OperationID, Code: "INVALID_REQUEST", Message: err.Error()}}, nil
|
return SyncResult{Applied: i, Failed: &FailedOperation{OperationID: op.OperationID, Code: "INVALID_REQUEST", Message: err.Error()}}, nil
|
||||||
}
|
}
|
||||||
already, err := s.Repository.Operations.Applied(ownerID, op.OperationID)
|
already, err := s.Repository.Operations.Applied(deviceID, op.OperationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SyncResult{}, err
|
return SyncResult{}, err
|
||||||
}
|
}
|
||||||
@@ -93,16 +96,16 @@ func (s *Resources) ApplyBatch(ownerID string, ops []SyncOperation) (SyncResult,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ackOnlyOps[op.Operation] {
|
if ackOnlyOps[op.Operation] {
|
||||||
if err := s.recordApplied(ownerID, op); err != nil {
|
if err := s.recordApplied(deviceID, op); err != nil {
|
||||||
return SyncResult{}, err
|
return SyncResult{}, err
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := s.applySyncOp(ownerID, op); err != nil {
|
if err := s.applySyncOp(userID, op); err != nil {
|
||||||
code, message := classifySyncError(err)
|
code, message := classifySyncError(err)
|
||||||
return SyncResult{Applied: i, Failed: &FailedOperation{OperationID: op.OperationID, Code: code, Message: message}}, nil
|
return SyncResult{Applied: i, Failed: &FailedOperation{OperationID: op.OperationID, Code: code, Message: message}}, nil
|
||||||
}
|
}
|
||||||
if err := s.recordApplied(ownerID, op); err != nil {
|
if err := s.recordApplied(deviceID, op); err != nil {
|
||||||
return SyncResult{}, err
|
return SyncResult{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,9 +233,9 @@ type ResourcePermission struct {
|
|||||||
UpdatedAt int64 `json:"updatedAt"`
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot returns the delta of effective permissions for the device since
|
// Snapshot returns the delta of effective permissions for the user since
|
||||||
// afterMs (epoch ms; 0 = all). V1 single-owner : toutes les ressources
|
// afterMs (epoch ms; 0 = all). V1 : pas encore de partage entre users — toutes
|
||||||
// appartiennent au device appelant (effective_access = owner).
|
// les ressources appartiennent au user appelant (effective_access = owner).
|
||||||
func (s *Resources) Snapshot(ownerID string, afterMs int64) ([]ResourcePermission, error) {
|
func (s *Resources) Snapshot(ownerID string, afterMs int64) ([]ResourcePermission, error) {
|
||||||
rows, err := s.Repo.ListOwned(ownerID, afterMs)
|
rows, err := s.Repo.ListOwned(ownerID, afterMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+27
-11
@@ -16,9 +16,13 @@ Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync)
|
|||||||
|
|
||||||
## 2. Identité et identifiants (invariants)
|
## 2. Identité et identifiants (invariants)
|
||||||
|
|
||||||
- **Device-first** : le device s'enregistre (`POST /devices`) avec son identité **générée localement** (`device_user_id` 32-hex mobile) et reçoit en échange un token **paseto** v4-local qu'il stocke. Requêtes suivantes : `Authorization: Bearer <token>` (toutes les routes **sauf `/health`**), résolu en `device_id` par middleware. V1 : pas de comptes utilisateurs (`users.user_id` reste NULL sur `devices`).
|
- **User-first (V1 finale)** : le device s'enregistre d'abord (`POST /devices`) avec son identité **générée localement** (`device_user_id` 32-hex mobile) — **réponse `{ "deviceId" }` uniquement, sans token**. Puis le client appelle `POST /auth/login` (`username` + `password` + `device_id`) pour obtenir son token **paseto** v4-local. Requêtes suivantes : `Authorization: Bearer <token>` (toutes les routes **sauf `/health`, `/devices`, `/auth/login`**).
|
||||||
- Au register, le device est **upserté** dans `devices` (`last_seen_at` rafraîchi) ; chaque nouvelle requête avec token est l'occasion de rafraîchir `last_seen_at`. Une ressource ne peut être créée que par un device enregistré (`resources.owner_id` → `devices.device_id`, FK).
|
- **Claims du token** : le **subject = `user_id`** (AUTORISE, clé de scoping de toutes les ressources) ; `device_id` est un **claim secondaire, porté mais NON autorisant seul** (idempotence outbox + jobs OCR). Résolu par middleware `RequireAuth` qui vérifie aussi que le compte existe toujours (`deleted_at IS NULL`).
|
||||||
- **Identifiants** : `resource_id`, `device_user_id`, `token` de share-link = **TEXT opaque 32-hex minuscule**, `^[0-9a-f]{32}$`. Le mobile génère toujours `lower(hex(randomblob(16)))` ; le serveur stocke **tel quel**, sans conversion UUID (cf. note V2.md). Contrainte serveur : `CHECK (col ~ '^[0-9a-f]{32}$')` sur toutes les colonnes id + FK.
|
- **TTL : 7 jours, sans refresh.** À expiration, le client re-logine (`POST /auth/login`). Le changement de mot de passe (`PATCH /users/me/password`) **n'invalide pas** les tokens déjà émis — limite V1 assumée (pas de liste de révocation) jusqu'à l'expiration.
|
||||||
|
- Au login, `devices.user_id` mémorise le **dernier user connecté** (INFORMATIF uniquement, jamais autorisant — recommande `POST /devices` → `POST /auth/login` pour un nouveau device, sans réutiliser le device d'un autre compte).
|
||||||
|
- **Identifiants** : `resource_id`, `device_user_id`, `user_id`, `is_admin`… = **TEXT opaque 32-hex minuscule**, `^[0-9a-f]{32}$`. Le mobile génère toujours `lower(hex(randomblob(16)))` ; le serveur stocke **tel quel**, sans conversion UUID (cf. note V2.md). Contrainte serveur : `CHECK (col ~ '^[0-9a-f]{32}$')` sur toutes les colonnes id + FK.
|
||||||
|
- **Usernames** : `username` = forme affichée ; l'unicité et la résolution portent sur `username_normalized` (lowercase + trim). Résolution d'un destinataire : `GET /users/resolve?username=` (exact uniquement, jamais de listing ni de préfixe — pas d'énumération de comptes).
|
||||||
|
- **Bootstrap** : au premier démarrage, si `users` est vide, `ADMIN_USERNAME`/`ADMIN_PASSWORD` (env) créent le **premier admin** ; absents → le serveur **refuse de démarrer**. L'env n'écrase jamais un compte existant.
|
||||||
- Horodatages échangés en **millisecondes epoch** (le mobile utilise `Date.now()`).
|
- Horodatages échangés en **millisecondes epoch** (le mobile utilise `Date.now()`).
|
||||||
|
|
||||||
## 3. Endpoints
|
## 3. Endpoints
|
||||||
@@ -26,7 +30,10 @@ Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync)
|
|||||||
| Méthode | Path | Requête | Réponse `data` | Statut absence |
|
| Méthode | Path | Requête | Réponse `data` | Statut absence |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| GET | `/health` | — | `{ "status": "healthy" }` | — |
|
| GET | `/health` | — | `{ "status": "healthy" }` | — |
|
||||||
| POST | `/devices` | `{ "deviceId": "…32-hex" }` (client-generated) | `{ "deviceId": "…32-hex", "token": "v4.local…" }` | `INVALID_DEVICE_ID` |
|
| POST | `/devices` | `{ "deviceId": "…32-hex" }` (client-generated) | `{ "deviceId": "…32-hex" }` — **aucun token** (V1 finale) | `INVALID_DEVICE_ID` |
|
||||||
|
| POST | `/auth/login` | `{ "username", "password", "device_id" }` | `{ "token", "expires_at" (ms), "user": { "id", "username", "is_admin" } }` | `UNAUTHORIZED` / `INVALID_DEVICE_ID` |
|
||||||
|
| GET | `/users/resolve` | query `username` (obligatoire) | `{ "id", "username" }` | `NOT_FOUND` |
|
||||||
|
| PATCH | `/users/me/password` | `{ "current_password", "new_password" }` | `{ "id" }` | `INVALID_PASSWORD` (403) |
|
||||||
| GET | `/files` | query `folderId?`, `page?`, `pageSize?`, `sort?` | `FileDto[]` (+ `meta`) | — |
|
| GET | `/files` | query `folderId?`, `page?`, `pageSize?`, `sort?` | `FileDto[]` (+ `meta`) | — |
|
||||||
| GET | `/files/:id` | — | `FileDto` | `NOT_FOUND` |
|
| GET | `/files/:id` | — | `FileDto` | `NOT_FOUND` |
|
||||||
| DELETE | `/files/:id` | — | `{ "id": "…" }` | `NOT_FOUND` |
|
| DELETE | `/files/:id` | — | `{ "id": "…" }` | `NOT_FOUND` |
|
||||||
@@ -60,14 +67,14 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?:
|
|||||||
|
|
||||||
- Multipart : champ `file` + `folderId?` optionnel. **Le client ne fixe jamais `Content-Type`** (le boundary doit être généré par la plateforme).
|
- Multipart : champ `file` + `folderId?` optionnel. **Le client ne fixe jamais `Content-Type`** (le boundary doit être généré par la plateforme).
|
||||||
- Limite : `MAX_FILE_SIZE_MB` (défaut 50). Dépassement → 413 `{ "error": { "code": "FILE_TOO_LARGE", … } }`.
|
- Limite : `MAX_FILE_SIZE_MB` (défaut 50). Dépassement → 413 `{ "error": { "code": "FILE_TOO_LARGE", … } }`.
|
||||||
- Le fichier physique est stocké sous `UPLOAD_DIR/<device_id>/<resource_id>.<ext>` ; la métadonnée est persistée en base et renvoyée en `FileDto`. Si la persistance de la métadonnée échoue (ex. `NAME_CONFLICT`), le fichier physique est supprimé.
|
- Le fichier physique est stocké sous `UPLOAD_DIR/<user_id>/<resource_id>.<ext>` ; la métadonnée est persistée en base et renvoyée en `FileDto`. Si la persistance de la métadonnée échoue (ex. `NAME_CONFLICT`), le fichier physique est supprimé.
|
||||||
|
|
||||||
## 5. OCR
|
## 5. OCR
|
||||||
|
|
||||||
- `POST /ocr/jobs { fileId }` → `OcrJob` immédiat (`status: queued`), traitement **asynchrone** (goroutine par job côté serveur, V1).
|
- `POST /ocr/jobs { fileId }` → `OcrJob` immédiat (`status: queued`), traitement **asynchrone** (goroutine par job côté serveur, V1).
|
||||||
- `GET /ocr/jobs/:id` → statut. Le mobile **poll toutes les 3s** jusqu'à `done`/`failed` (`hooks/useUpload.ts`). Cycle : `queued → processing → done | failed` ; `done` renvoie `text`, `failed` renvoie `error`.
|
- `GET /ocr/jobs/:id` → statut. Le mobile **poll toutes les 3s** jusqu'à `done`/`failed` (`hooks/useUpload.ts`). Cycle : `queued → processing → done | failed` ; `done` renvoie `text`, `failed` renvoie `error`.
|
||||||
- Moteur : **Tesseract en appel système** (`ocr/tesseract.go`), langue `OCR_LANG` (défaut `fra+eng`). Les images sont passées directement à `tesseract` ; les **PDF** subissent une extraction du calque texte (`ledongthuc/pdf`, déjà en go.mod) — un PDF scanné produit un texte vide plutôt qu'un rendu/OCR (hors scope V1).
|
- Moteur : **Tesseract en appel système** (`ocr/tesseract.go`), langue `OCR_LANG` (défaut `fra+eng`). Les images sont passées directement à `tesseract` ; les **PDF** subissent une extraction du calque texte (`ledongthuc/pdf`, déjà en go.mod) — un PDF scanné produit un texte vide plutôt qu'un rendu/OCR (hors scope V1).
|
||||||
- `fileId` inconnu/pas du device → `NOT_FOUND`. Fichier physique introuvable (ex. suppression manuelle sous `UPLOAD_DIR`) → job `failed` `"file not readable"`.
|
- `fileId` inconnu/pas du user → `NOT_FOUND`. Fichier physique introuvable (ex. suppression manuelle sous `UPLOAD_DIR`) → job `failed` `"file not readable"`. Le job est créé par le device courant (`ocr_jobs.device_id`) mais la validation de la ressource est scopée par le **user**.
|
||||||
|
|
||||||
## 6. Contrat de sync (outbox + snapshot)
|
## 6. Contrat de sync (outbox + snapshot)
|
||||||
|
|
||||||
@@ -90,7 +97,7 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `operation` ∈ `create_resource | update_metadata | delete_resource | move_resource | share | revoke_share | update_share | create_link | revoke_link` (cf. `PendingOperationType` mobile).
|
- `operation` ∈ `create_resource | update_metadata | delete_resource | move_resource | share | revoke_share | update_share | create_link | revoke_link` (cf. `PendingOperationType` mobile).
|
||||||
- **Idempotence** : contrainte d'unicité serveur `(device_id, operation_id)`. Pour chaque op : si déjà traitée → **no-op** (comptée comme appliquée, les doublons arrivent à cause du backoff/retry). Sinon appliquée si valide.
|
- L'idempotence outbox reste **par device** : `UNIQUE(device_id, operation_id)` (la réinscription d'un device avec un login différent ne réutilise pas l'historique outbox d'un autre compte). Pour chaque op : si déjà traitée → **no-op** (comptée comme appliquée, les doublons arrivent à cause du backoff/retry). Sinon appliquée si valide.
|
||||||
- **Ordre** : les opérations sont appliquées **séquentiellement**, dans l'ordre du batch. Le serveur **s'arrête à la première erreur non-idempotente** et renvoie l'index atteint — le client reprend à cet index.
|
- **Ordre** : les opérations sont appliquées **séquentiellement**, dans l'ordre du batch. Le serveur **s'arrête à la première erreur non-idempotente** et renvoie l'index atteint — le client reprend à cet index.
|
||||||
- Réponse : `2xx` avec `{ "applied": int, "failed": { "operation_id": int, "code": string, "message": string } | null }` (`applied` = index de la prochaine op à envoyer).
|
- Réponse : `2xx` avec `{ "applied": int, "failed": { "operation_id": int, "code": string, "message": string } | null }` (`applied` = index de la prochaine op à envoyer).
|
||||||
- Côté client, le `pushStatus` (pending/synced/failed) des shares/share_links est **dérivé** de l'état des opérations de l'outbox ; dead-letter après `MAX_PENDING_ATTEMPTS` (= 5). **Côté serveur, les ops `share | revoke_share | update_share | create_link | revoke_link` sont accusées réception mais ne créent aucun état** (V1 single-owner, pas de table shares serveur) — la dérivation du pushStatus reste purement client.
|
- Côté client, le `pushStatus` (pending/synced/failed) des shares/share_links est **dérivé** de l'état des opérations de l'outbox ; dead-letter après `MAX_PENDING_ATTEMPTS` (= 5). **Côté serveur, les ops `share | revoke_share | update_share | create_link | revoke_link` sont accusées réception mais ne créent aucun état** (V1 single-owner, pas de table shares serveur) — la dérivation du pushStatus reste purement client.
|
||||||
@@ -103,7 +110,7 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?:
|
|||||||
|
|
||||||
### 6.2 Snapshot — `GET /sync/permissions?after=<cached_at_ms>`
|
### 6.2 Snapshot — `GET /sync/permissions?after=<cached_at_ms>`
|
||||||
|
|
||||||
- Renvoie le delta (ou l'ensemble) des permissions effectives pour le device appelant, chacune sous la forme exacte consommée par `canAccess` :
|
- Renvoie le delta (ou l'ensemble) des permissions effectives pour le **user** appelant, chacune sous la forme exacte consommée par `canAccess` :
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type ResourcePermission = {
|
type ResourcePermission = {
|
||||||
@@ -111,7 +118,7 @@ type ResourcePermission = {
|
|||||||
resourceType: 'folder' | 'file';
|
resourceType: 'folder' | 'file';
|
||||||
effectiveAccess: 'viewer' | 'commenter' | 'editor' | 'owner';
|
effectiveAccess: 'viewer' | 'commenter' | 'editor' | 'owner';
|
||||||
inherit: boolean;
|
inherit: boolean;
|
||||||
ownerId: string | null; // device ownership si applicable
|
ownerId: string | null; // ownership USER si applicable
|
||||||
sharedById: string | null;
|
sharedById: string | null;
|
||||||
expiresAt: number | null; // ms epoch ; null = jamais
|
expiresAt: number | null; // ms epoch ; null = jamais
|
||||||
cachedAt: number; // ms epoch — horodatage du snapshot (TTL 24h)
|
cachedAt: number; // ms epoch — horodatage du snapshot (TTL 24h)
|
||||||
@@ -123,7 +130,7 @@ type ResourcePermission = {
|
|||||||
1. Rang : `viewer = 1 < commenter = 2 < editor = 3 < owner = 4`.
|
1. Rang : `viewer = 1 < commenter = 2 < editor = 3 < owner = 4`.
|
||||||
2. La permission **exacte sur le nœud** est autoritaire (elle n'est pas annulée par son propre `inherit=false`).
|
2. La permission **exacte sur le nœud** est autoritaire (elle n'est pas annulée par son propre `inherit=false`).
|
||||||
3. Les ancêtres propagent **uniquement si leur relation a `inherit = true`** ; une relation expirée (`expires_at` passé) est ignorée **et ne propage pas**.
|
3. Les ancêtres propagent **uniquement si leur relation a `inherit = true`** ; une relation expirée (`expires_at` passé) est ignorée **et ne propage pas**.
|
||||||
4. `owner_id` == device appelant → `owner` (fallback, quel que soit le niveau remonté).
|
4. `owner_id` == le user appelant → `owner` (fallback, quel que soit le niveau remonté).
|
||||||
5. Le **rang le plus élevé** l'emporte ; sans relation applicable et sans ownership → la ressource n'est pas dans le snapshot.
|
5. Le **rang le plus élevé** l'emporte ; sans relation applicable et sans ownership → la ressource n'est pas dans le snapshot.
|
||||||
- **TTL / stale** : après `PERMISSION_TTL_MS` (= 24h) sans reseed, `canAccess` **downgrade en lecture seule** (`viewer`) vers le cache.
|
- **TTL / stale** : après `PERMISSION_TTL_MS` (= 24h) sans reseed, `canAccess` **downgrade en lecture seule** (`viewer`) vers le cache.
|
||||||
|
|
||||||
@@ -133,4 +140,13 @@ type ResourcePermission = {
|
|||||||
|
|
||||||
## 7. Codes d'erreur courants
|
## 7. Codes d'erreur courants
|
||||||
|
|
||||||
`NOT_FOUND`, `NOT_IMPLEMENTED` (501 temporaire sur les routes non construites — état actuel : **toutes les routes V1 sont réelles** : files CRUD/upload/search, folders, devices, health, sync/ops, sync/permissions, ocr/jobs), `FILE_TOO_LARGE` (413), `NAME_CONFLICT` (409 — même nom dans le même parent, cf. `UNIQUE(parent_id, name)`, **ou à la racine**, index partiel `(owner_id, name) WHERE parent_id IS NULL`), `NETWORK_ERROR` (côté client), `INVALID_RESPONSE` (côté client — 2xx mais corps d'enveloppe invalide), `HTTP_<status>` (fallback). Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé.
|
- Authentification : `UNAUTHORIZED` (**401** — token manquant/invalide/expiré, compte supprimé, OU identifiants de login erronés : **indistinguables par design**, même code+message), `INVALID_DEVICE_ID` (**400** — device non enregistré au login).
|
||||||
|
- Ressources : `NOT_FOUND` (404), `NAME_CONFLICT` (409 — même nom dans le même parent, cf. `UNIQUE(parent_id, name)`, **ou à la racine**, index partiel `(user_id, name) WHERE parent_id IS NULL`), `FILE_TOO_LARGE` (413), `INVALID_PASSWORD` (403 sur `PATCH /users/me/password`).
|
||||||
|
- Client-only : `NETWORK_ERROR`, `INVALID_RESPONSE` (2xx mais corps d'enveloppe invalide), `HTTP_<status>` (fallback). Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé.
|
||||||
|
- **V1 finale : toutes les routes sont réelles** (pas de 501 restant).
|
||||||
|
|
||||||
|
## 8. Moteur de login (règles de sécurité)
|
||||||
|
|
||||||
|
- `POST /auth/login` : le **device doit exister** (`POST /devices` d'abord, sinon 400 `INVALID_DEVICE_ID`). La vérification du mot de passe est **constant-time** (`argon2id`, comparaison `subtle`) et le délai est **égalisé** entre « username inconnu » et « mauvais mot de passe » (vérification contre un dummy-hash) — les deux produisent exactement la même réponse 401.
|
||||||
|
- `GET /users/resolve` : résolution **exacte** du `username_normalized` uniquement ; ne renvoie **jamais** `email` ni `is_admin` (ni listing, ni préfixe → pas d'énumération de comptes).
|
||||||
|
- `PATCH /users/me/password` : exige `current_password` (mauvais curl → 403). Minimum 8 caractères. **Limite V1** : tokens émis non révoqués (validité 7 j), et `is_admin`/`ADMIN_*` non modifiables par API.
|
||||||
Reference in New Issue
Block a user