From c2c7dddba63d20b75ee75a4e3e2c024198f5a887 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 10 Sep 2026 21:21:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20identit=C3=A9=20user-first=20?= =?UTF-8?q?=E2=80=94=20login/password,=20ownership=20user,=20fin=20du=20to?= =?UTF-8?q?ken=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/.env.example | 16 + backend/cmd/server/main.go | 6 + backend/cmd/server/router_test.go | 4 + backend/config/config.go | 4 + .../migrations/000006_users_identity.down.sql | 9 + .../migrations/000006_users_identity.up.sql | 19 + .../000007_resources_user_ownership.down.sql | 9 + .../000007_resources_user_ownership.up.sql | 15 + backend/db/migrations_test.go | 68 ++++ backend/handlers/auth.go | 178 ++++++++++ backend/handlers/auth_test.go | 325 ++++++++++++++---- backend/handlers/devices.go | 12 +- backend/handlers/files.go | 24 +- backend/handlers/files_test.go | 82 +++-- backend/handlers/middleware.go | 41 ++- backend/handlers/ocr.go | 4 +- backend/handlers/ocr_test.go | 32 +- backend/handlers/router.go | 18 +- backend/handlers/sync.go | 8 +- backend/handlers/sync_test.go | 18 +- backend/pkg/auth/auth.go | 39 ++- backend/pkg/auth/auth_test.go | 62 +++- backend/pkg/passwd/passwd.go | 104 ++++++ backend/pkg/passwd/passwd_test.go | 62 ++++ backend/repository/devices.go | 13 +- backend/repository/repository.go | 2 + backend/repository/resources.go | 26 +- backend/repository/resources_test.go | 21 +- backend/repository/users.go | 109 ++++++ backend/service/bootstrap.go | 47 +++ backend/service/ocr.go | 22 +- backend/service/resources.go | 4 +- backend/service/sync.go | 19 +- docs/api-v1.md | 38 +- 34 files changed, 1237 insertions(+), 223 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/db/migrations/000006_users_identity.down.sql create mode 100644 backend/db/migrations/000006_users_identity.up.sql create mode 100644 backend/db/migrations/000007_resources_user_ownership.down.sql create mode 100644 backend/db/migrations/000007_resources_user_ownership.up.sql create mode 100644 backend/handlers/auth.go create mode 100644 backend/pkg/passwd/passwd.go create mode 100644 backend/pkg/passwd/passwd_test.go create mode 100644 backend/repository/users.go create mode 100644 backend/service/bootstrap.go diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..03a2e34 --- /dev/null +++ b/backend/.env.example @@ -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 \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4dd8557..8743b75 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -44,6 +44,12 @@ func main() { handlers.Auth = authManager 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( repo, cfg.UploadDir, diff --git a/backend/cmd/server/router_test.go b/backend/cmd/server/router_test.go index f844c63..91e0789 100644 --- a/backend/cmd/server/router_test.go +++ b/backend/cmd/server/router_test.go @@ -9,6 +9,7 @@ import ( var expectedRoutes = []string{ "GET /api/v1/health", "POST /api/v1/devices", + "POST /api/v1/auth/login", "GET /api/v1/files", "GET /api/v1/files/:id", @@ -17,6 +18,9 @@ var expectedRoutes = []string{ "GET /api/v1/files/folders", "POST /api/v1/files/upload", + "GET /api/v1/users/resolve", + "PATCH /api/v1/users/me/password", + "POST /api/v1/ocr/jobs", "GET /api/v1/ocr/jobs/:id", diff --git a/backend/config/config.go b/backend/config/config.go index 6974bab..e3c78c9 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -14,6 +14,8 @@ type ApplicationConfig struct { MaxFileSizeMB int64 OcrLang string AuthSecret string + AdminUsername string + AdminPassword string } func LoadApplicationConfig() (error, *ApplicationConfig) { @@ -38,6 +40,8 @@ func LoadApplicationConfig() (error, *ApplicationConfig) { MaxFileSizeMB: int64(maxSize), OcrLang: get("OCR_LANG", "fra+eng"), AuthSecret: get("AUTH_SECRET", "dev-secret-change-me"), + AdminUsername: get("ADMIN_USERNAME", ""), + AdminPassword: get("ADMIN_PASSWORD", ""), } } diff --git a/backend/db/migrations/000006_users_identity.down.sql b/backend/db/migrations/000006_users_identity.down.sql new file mode 100644 index 0000000..cc1c2e3 --- /dev/null +++ b/backend/db/migrations/000006_users_identity.down.sql @@ -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; \ No newline at end of file diff --git a/backend/db/migrations/000006_users_identity.up.sql b/backend/db/migrations/000006_users_identity.up.sql new file mode 100644 index 0000000..a62a31a --- /dev/null +++ b/backend/db/migrations/000006_users_identity.up.sql @@ -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. \ No newline at end of file diff --git a/backend/db/migrations/000007_resources_user_ownership.down.sql b/backend/db/migrations/000007_resources_user_ownership.down.sql new file mode 100644 index 0000000..89f374a --- /dev/null +++ b/backend/db/migrations/000007_resources_user_ownership.down.sql @@ -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; \ No newline at end of file diff --git a/backend/db/migrations/000007_resources_user_ownership.up.sql b/backend/db/migrations/000007_resources_user_ownership.up.sql new file mode 100644 index 0000000..4b8a163 --- /dev/null +++ b/backend/db/migrations/000007_resources_user_ownership.up.sql @@ -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). \ No newline at end of file diff --git a/backend/db/migrations_test.go b/backend/db/migrations_test.go index 6018e71..c4f8640 100644 --- a/backend/db/migrations_test.go +++ b/backend/db/migrations_test.go @@ -110,6 +110,74 @@ func TestMigrationsUpDown(t *testing.T) { assertHexCheck(t, conn, "devices", "device_id") assertHexCheck(t, conn, "resources", "resource_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 var opType string diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go new file mode 100644 index 0000000..e01db2f --- /dev/null +++ b/backend/handlers/auth.go @@ -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}) +} diff --git a/backend/handlers/auth_test.go b/backend/handlers/auth_test.go index 5195fff..7a03585 100644 --- a/backend/handlers/auth_test.go +++ b/backend/handlers/auth_test.go @@ -11,19 +11,22 @@ import ( "github.com/vaultdrop/backend/dbtest" "github.com/vaultdrop/backend/pkg/auth" + "github.com/vaultdrop/backend/pkg/passwd" "github.com/vaultdrop/backend/repository" "github.com/vaultdrop/backend/service" ) const authTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_handlers_auth_test?sslmode=disable" +const ( + authTestPassword = "vaultdrop-test-password" + authTestDevice = "0123456789abcdef0123456789abcdef" +) + func newTestRouterForAuth() *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() - r.POST("/devices", DevicesRegister) - grp := r.Group("") - grp.Use(RequireDevice) - grp.GET("/files", FilesList) + RegisterRoutes(r) return r } @@ -36,93 +39,291 @@ func setTestStore(t *testing.T) *repository.Repository { return repo } -func TestDevicesRegisterValid(t *testing.T) { - m, _ := auth.NewManager("test-secret") - Auth = m - defer func() { Auth = nil }() +func setTestAuth(t *testing.T) { + t.Helper() + Auth, _ = auth.NewManager("test-secret") + 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) - deviceID := "0123456789abcdef0123456789abcdef" - body := `{"deviceId":"` + deviceID + `"}` - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/devices", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - newTestRouterForAuth().ServeHTTP(rec, req) - + rec := doAuth(t, newTestRouterForAuth(), http.MethodPost, "/api/v1/devices", "", `{"deviceId":"`+authTestDevice+`"}`) if rec.Code != 200 { t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) } - - var envelope struct { - Data struct { - DeviceID string `json:"deviceId"` - Token string `json:"token"` - } `json:"data"` + var env struct { + Data map[string]any `json:"data"` } - if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { - t.Fatalf("unmarshal: %v", err) + _ = json.Unmarshal(rec.Body.Bytes(), &env) + if _, hasToken := env.Data["token"]; hasToken { + t.Error("POST /devices ne doit plus émettre de token") } - if envelope.Data.DeviceID != deviceID { - t.Errorf("deviceId = %q", envelope.Data.DeviceID) + if env.Data["deviceId"] != authTestDevice { + t.Errorf("deviceId = %v", env.Data["deviceId"]) } - verified, err := m.Verify(envelope.Data.Token) - 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) + exists, err := repo.Devices.Exists(authTestDevice) 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) { - Auth, _ = auth.NewManager("test-secret") - defer func() { Auth = nil }() - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/devices", strings.NewReader(`{"deviceId":"UPPERCASEANDTOOLONG"}`)) - req.Header.Set("Content-Type", "application/json") - newTestRouterForAuth().ServeHTTP(rec, req) + setTestAuth(t) + setTestStore(t) + rec := doAuth(t, newTestRouterForAuth(), http.MethodPost, "/api/v1/devices", "", `{"deviceId":"UPPERCASEANDTOOLONG"}`) 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) { - Auth, _ = auth.NewManager("test-secret") - defer func() { Auth = nil }() - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/files", nil) - newTestRouterForAuth().ServeHTTP(rec, req) +func TestRequireAuthRejectsMissingToken(t *testing.T) { + setTestAuth(t) + setTestStore(t) + rec := doAuth(t, newTestRouterForAuth(), http.MethodGet, "/api/v1/files", "", "") if rec.Code != 401 { t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) } } -func TestRequireDeviceAcceptsValidToken(t *testing.T) { - Auth, _ = auth.NewManager("test-secret") - defer func() { Auth = nil }() +func TestAuthLoginFlow(t *testing.T) { + setTestAuth(t) + repo := setTestStore(t) + adminID := createAdmin(t, repo, "admin", authTestPassword) + r := newTestRouterForAuth() - deviceID := "0123456789abcdef0123456789abcdef" - signed, err := Auth.Issue(deviceID) - if err != nil { - t.Fatalf("Issue: %v", err) + // Enregistrement du device puis login → token utilisable + rec := doAuth(t, r, http.MethodPost, "/api/v1/devices", "", `{"deviceId":"`+authTestDevice+`"}`) + if rec.Code != 200 { + t.Fatalf("register: status %d", rec.Code) } - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/files", nil) - req.Header.Set("Authorization", "Bearer "+signed) - newTestRouterForAuth().ServeHTTP(rec, req) + token, userID := loginOK(t, r, "admin", authTestPassword, authTestDevice) + if userID != adminID { + t.Errorf("login user.id = %q, want %q", userID, adminID) + } - // Sans Store le handler répond SERVICE_UNAVAILABLE (503) — le point est - // que la requête a dépassé le middleware (jamais 401). + // Le token fonctionne sur un endpoint protégé + 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 { - 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) } } diff --git a/backend/handlers/devices.go b/backend/handlers/devices.go index 07f8aff..459ea15 100644 --- a/backend/handlers/devices.go +++ b/backend/handlers/devices.go @@ -34,12 +34,10 @@ func DevicesRegister(c *gin.Context) { return } - token, err := Auth.Issue(req.DeviceID) - if err != nil { - api.Error(c, 500, "TOKEN_ERROR", "could not issue token") - return - } - - api.OK(c, gin.H{"deviceId": req.DeviceID, "token": token}) + // POST /devices n'émet PLUS de token : de l'identité user-first (V1), le + // device s'enregistre pour exister, puis le client appelle POST /auth/login + // avec username/password + device_id pour obtenir son token (cf. + // docs/api-v1.md §2). Réponse : { deviceId } uniquement. + api.OK(c, gin.H{"deviceId": req.DeviceID}) } diff --git a/backend/handlers/files.go b/backend/handlers/files.go index 1ca19f0..c2dc034 100644 --- a/backend/handlers/files.go +++ b/backend/handlers/files.go @@ -26,13 +26,13 @@ func FilesList(c *gin.Context) { api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") return } - deviceID := c.GetString(DeviceIDKey) + userID := c.GetString(UserIDKey) page := intParam(c.Query("page"), 1) pageSize := intParam(c.Query("pageSize"), 50) if 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 { writeError(c, err) return @@ -45,13 +45,13 @@ func FilesGet(c *gin.Context) { api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") return } - deviceID := c.GetString(DeviceIDKey) + userID := c.GetString(UserIDKey) id := c.Param("id") if !deviceIDPattern.MatchString(id) { api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found") return } - file, err := Store.GetFile(deviceID, id) + file, err := Store.GetFile(userID, id) if err != nil { writeError(c, err) return @@ -64,13 +64,13 @@ func FilesDelete(c *gin.Context) { api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") return } - deviceID := c.GetString(DeviceIDKey) + userID := c.GetString(UserIDKey) id := c.Param("id") if !deviceIDPattern.MatchString(id) { api.Error(c, http.StatusNotFound, "NOT_FOUND", "file not found") return } - deletedID, err := Store.DeleteFile(deviceID, id) + deletedID, err := Store.DeleteFile(userID, id) if err != nil { writeError(c, err) return @@ -83,7 +83,7 @@ func FilesUpload(c *gin.Context) { api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") return } - deviceID := c.GetString(DeviceIDKey) + userID := c.GetString(UserIDKey) folderID := c.PostForm("folderId") c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, Store.MaxFileSize+1) @@ -102,7 +102,7 @@ func FilesUpload(c *gin.Context) { return } - dto, err := Store.Upload(deviceID, file, folderID) + dto, err := Store.Upload(userID, file, folderID) if err != nil { writeError(c, err) return @@ -115,8 +115,8 @@ func FoldersList(c *gin.Context) { api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") return } - deviceID := c.GetString(DeviceIDKey) - folders, err := Store.ListRootFolders(deviceID) + userID := c.GetString(UserIDKey) + folders, err := Store.ListRootFolders(userID) if err != nil { writeError(c, err) return @@ -129,7 +129,7 @@ func FilesSearch(c *gin.Context) { api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") return } - deviceID := c.GetString(DeviceIDKey) + userID := c.GetString(UserIDKey) q := strings.TrimSpace(c.Query("q")) if 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 { pageSize = 200 } - files, total, err := Store.SearchFiles(deviceID, q, page, pageSize) + files, total, err := Store.SearchFiles(userID, q, page, pageSize) if err != nil { writeError(c, err) return diff --git a/backend/handlers/files_test.go b/backend/handlers/files_test.go index e7f494e..80fe501 100644 --- a/backend/handlers/files_test.go +++ b/backend/handlers/files_test.go @@ -14,6 +14,7 @@ import ( "github.com/vaultdrop/backend/dbtest" "github.com/vaultdrop/backend/handlers" "github.com/vaultdrop/backend/pkg/auth" + "github.com/vaultdrop/backend/pkg/passwd" "github.com/vaultdrop/backend/repository" "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() - body := fmt.Sprintf(`{"deviceId":%q}`, deviceID) - rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/devices", "", []byte(body), "application/json") + rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/devices", "", []byte(fmt.Sprintf(`{"deviceId":%q}`, deviceID)), "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 { Data struct { Token string `json:"token"` + User struct { + ID string `json:"id"` + } `json:"user"` } `json:"data"` } 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 == "" { - t.Fatalf("register: pas de token (status %d)", rec.Code) + if env.Data.Token == "" || env.Data.User.ID == "" { + 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 { @@ -155,11 +187,13 @@ func TestFilesFlow(t *testing.T) { r, _, repo := setup(t) deviceA := repository.NewID() deviceB := repository.NewID() - tokenA := registerDevice(t, r, deviceA) - tokenB := registerDevice(t, r, deviceB) + passA := "files-test-password-a" + 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() - 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) } @@ -188,8 +222,8 @@ func TestFilesFlow(t *testing.T) { rec = uploadMultipart(t, r, tokenA, folderID, "doc.txt", []byte("doc")) expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe") - // Création de fichiers du device B - if err := repo.Resources.InsertFile(deviceB, repository.NewID(), "secret.txt", "", 4, nil, nil); err != nil { + // Création de fichiers du user B + if err := repo.Resources.InsertFile(userB, repository.NewID(), "secret.txt", "", 4, nil, nil); err != nil { t.Fatalf("insert B file: %v", err) } @@ -218,7 +252,7 @@ func TestFilesFlow(t *testing.T) { 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, "") expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-cross-device") @@ -252,15 +286,15 @@ func TestFilesFlow(t *testing.T) { func TestSearchFiles(t *testing.T) { r, _, repo := setup(t) 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) } - 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) } - 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) } @@ -295,9 +329,9 @@ func TestSearchFiles(t *testing.T) { } func TestUploadTooLarge(t *testing.T) { - r, _, _ := setup(t) + r, _, repo := setup(t) 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")) expectError(t, rec, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "upload-big") @@ -307,16 +341,16 @@ func TestFoldersListAndScoping(t *testing.T) { r, _, repo := setup(t) deviceA := repository.NewID() deviceB := repository.NewID() - tokenA := registerDevice(t, r, deviceA) - registerDevice(t, r, deviceB) + tokenA, userA := registerAndLogin(t, r, repo, testUserUsername(deviceA, "fa"), "folder-test-password", deviceA) + _, 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) } - 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) } - 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) } diff --git a/backend/handlers/middleware.go b/backend/handlers/middleware.go index bc152aa..3bb2f92 100644 --- a/backend/handlers/middleware.go +++ b/backend/handlers/middleware.go @@ -1,39 +1,64 @@ package handlers import ( + "errors" + "net/http" "strings" "github.com/gin-gonic/gin" "github.com/vaultdrop/backend/pkg/api" "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). var Auth *auth.Manager -// RequireDevice authenticates the bearer paseto token and stores the resolved -// device_id in the gin context (cf. docs/api-v1.md §2). -func RequireDevice(c *gin.Context) { +// RequireAuth authenticates the bearer paseto token. Le subject (user_id) +// autorise — c'est la clé de scoping de toutes les ressources ; device_id est +// 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") token, found := strings.CutPrefix(header, "Bearer ") if Auth == nil || !found || token == "" { - api.Error(c, 401, "UNAUTHORIZED", "missing bearer token") + api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "missing bearer token") c.Abort() return } - deviceID, err := Auth.Verify(token) + identity, err := Auth.Verify(token) if err != nil { - api.Error(c, 401, "UNAUTHORIZED", "invalid or expired token") + api.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired token") c.Abort() 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() } diff --git a/backend/handlers/ocr.go b/backend/handlers/ocr.go index 36e466e..027a3e2 100644 --- a/backend/handlers/ocr.go +++ b/backend/handlers/ocr.go @@ -18,7 +18,7 @@ func OcrJobsCreate(c *gin.Context) { api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") return } - deviceID := c.GetString(DeviceIDKey) + userID := c.GetString(UserIDKey) var req ocrJobRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -26,7 +26,7 @@ func OcrJobsCreate(c *gin.Context) { return } - job, err := Ocr.Create(deviceID, req.FileID) + job, err := Ocr.Create(userID, c.GetString(DeviceIDKey), req.FileID) if err != nil { writeError(c, err) return diff --git a/backend/handlers/ocr_test.go b/backend/handlers/ocr_test.go index d2ebbae..46e5e31 100644 --- a/backend/handlers/ocr_test.go +++ b/backend/handlers/ocr_test.go @@ -30,7 +30,7 @@ func (s stubEngine) ExtractText(_ context.Context, _ string, _ string) (string, return s.text, nil } -func setupOcr(t *testing.T) (*gin.Engine, string) { +func setupOcr(t *testing.T) (*gin.Engine, string, *repository.Repository) { t.Helper() conn := dbtest.OpenTestDatabase(t, handlersTestURL) repo := repository.NewRepository(conn) @@ -45,7 +45,7 @@ func setupOcr(t *testing.T) (*gin.Engine, string) { gin.SetMode(gin.TestMode) r := gin.New() handlers.RegisterRoutes(r) - return r, uploadDir + return r, uploadDir, repo } type ocrJobDTO struct { @@ -75,20 +75,20 @@ func waitTerminal(t *testing.T, r *gin.Engine, token, jobID string) ocrJobDTO { } func TestOcrJobsLifecycle(t *testing.T) { - r, uploadDir := setupOcr(t) + r, uploadDir, repo := setupOcr(t) 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//.txt) + // Fichier + fichier physique (simule UPLOAD_DIR//.txt — après + // 000007 le répertoire d'upload est scopé par USER) 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) } - 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) } - repo := handlers.Store.Repository - if err := repo.Resources.InsertFile(device, fileID, "scan.png", "", 128, nil, nil); err != nil { + if err := repo.Resources.InsertFile(user, fileID, "scan.png", "", 128, nil, nil); err != nil { t.Fatalf("insert: %v", err) } @@ -115,15 +115,15 @@ func TestOcrJobsLifecycle(t *testing.T) { expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-missing-file") } -func TestOcrJobsScopedByDevice(t *testing.T) { - r, _ := setupOcr(t) +func TestOcrJobsScopedByOwner(t *testing.T) { + r, _, repo := setupOcr(t) deviceA := repository.NewID() deviceB := repository.NewID() - tokenA := registerDevice(t, r, deviceA) - tokenB := registerDevice(t, r, deviceB) + tokenA, userA := registerAndLogin(t, r, repo, testUserUsername(deviceA, "oc"), "ocr-test-password", deviceA) + tokenB, _ := registerAndLogin(t, r, repo, testUserUsername(deviceB, "od"), "ocr-test-password-b", deviceB) 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) } body, _ := json.Marshal(map[string]string{"fileId": fileID}) @@ -132,9 +132,9 @@ func TestOcrJobsScopedByDevice(t *testing.T) { var created ocrJobDTO _ = 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, "") - 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 { diff --git a/backend/handlers/router.go b/backend/handlers/router.go index 2e4192e..fb649c2 100644 --- a/backend/handlers/router.go +++ b/backend/handlers/router.go @@ -1,20 +1,24 @@ package handlers import ( + "strings" + "github.com/gin-gonic/gin" ) // 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) { public := r.Group("/api/v1") { public.GET("/health", Health) public.POST("/devices", DevicesRegister) + public.POST("/auth/login", AuthLogin) } protected := r.Group("/api/v1") - protected.Use(RequireDevice) + protected.Use(RequireAuth) { protected.GET("/files", FilesList) protected.GET("/files/search", FilesSearch) @@ -23,6 +27,9 @@ func RegisterRoutes(r *gin.Engine) { protected.GET("/files/folders", FoldersList) protected.POST("/files/upload", FilesUpload) + protected.GET("/users/resolve", ResolveUser) + protected.PATCH("/users/me/password", ChangePassword) + protected.POST("/ocr/jobs", OcrJobsCreate) protected.GET("/ocr/jobs/:id", OcrJobsGet) @@ -30,3 +37,10 @@ func RegisterRoutes(r *gin.Engine) { 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)) +} diff --git a/backend/handlers/sync.go b/backend/handlers/sync.go index 9328741..2530283 100644 --- a/backend/handlers/sync.go +++ b/backend/handlers/sync.go @@ -18,7 +18,7 @@ func SyncOpsPush(c *gin.Context) { api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") return } - deviceID := c.GetString(DeviceIDKey) + userID := c.GetString(UserIDKey) var req syncOpsRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -26,7 +26,7 @@ func SyncOpsPush(c *gin.Context) { return } - result, err := Store.ApplyBatch(deviceID, req.Operations) + result, err := Store.ApplyBatch(userID, c.GetString(DeviceIDKey), req.Operations) if err != nil { writeError(c, err) return @@ -39,13 +39,13 @@ func SyncPermissionsGet(c *gin.Context) { api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") return } - deviceID := c.GetString(DeviceIDKey) + userID := c.GetString(UserIDKey) after := c.Query("after") var afterMs int64 if after != "" { afterMs = int64(intParam(after, 0)) } - perms, err := Store.Snapshot(deviceID, afterMs) + perms, err := Store.Snapshot(userID, afterMs) if err != nil { writeError(c, err) return diff --git a/backend/handlers/sync_test.go b/backend/handlers/sync_test.go index faf93ba..c92a6ee 100644 --- a/backend/handlers/sync_test.go +++ b/backend/handlers/sync_test.go @@ -26,9 +26,9 @@ func op(operationID int64, resourceID, operation, resourceType string, payload m } func TestSyncOpsApplySequential(t *testing.T) { - r, _, _ := setup(t) + r, _, repo := setup(t) device := repository.NewID() - token := registerDevice(t, r, device) + token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "sa"), "sync-test-password", device) folderID := repository.NewID() fileID := repository.NewID() @@ -78,9 +78,9 @@ func TestSyncOpsApplySequential(t *testing.T) { } func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) { - r, _, _ := setup(t) + r, _, repo := setup(t) device := repository.NewID() - token := registerDevice(t, r, device) + token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "sb"), "sync-test-password", device) folderID := repository.NewID() dupeID := repository.NewID() @@ -126,10 +126,10 @@ func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) { func TestSyncOpsDeleteIdempotent(t *testing.T) { r, _, repo := setup(t) device := repository.NewID() - token := registerDevice(t, r, device) + token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sc"), "sync-test-password", device) 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) } @@ -155,10 +155,10 @@ func TestSyncOpsDeleteIdempotent(t *testing.T) { func TestSnapshotPermissions(t *testing.T) { r, _, repo := setup(t) device := repository.NewID() - token := registerDevice(t, r, device) + token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sd"), "sync-test-password", device) 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) } @@ -174,7 +174,7 @@ func TestSnapshotPermissions(t *testing.T) { if err := json.Unmarshal(env.Data, &perms); err != nil { 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) } if perms[0].CachedAt == 0 { diff --git a/backend/pkg/auth/auth.go b/backend/pkg/auth/auth.go index fbd0795..528d6a3 100644 --- a/backend/pkg/auth/auth.go +++ b/backend/pkg/auth/auth.go @@ -8,11 +8,22 @@ import ( "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") -// 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 { key paseto.V4SymmetricKey } @@ -26,24 +37,32 @@ func NewManager(secret string) (*Manager, error) { 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() token := paseto.NewToken() token.SetIssuedAt(now) token.SetNotBefore(now) 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 } -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) if err != nil { - return "", ErrInvalidToken + return Identity{}, ErrInvalidToken } - subject, err := parsed.GetSubject() - if err != nil { - return "", ErrInvalidToken + userID, err := parsed.GetSubject() + if err != nil || userID == "" { + 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 } diff --git a/backend/pkg/auth/auth_test.go b/backend/pkg/auth/auth_test.go index c3ed868..844c7b0 100644 --- a/backend/pkg/auth/auth_test.go +++ b/backend/pkg/auth/auth_test.go @@ -3,6 +3,14 @@ package auth import ( "strings" "testing" + "time" + + "aidanwoods.dev/go-paseto" +) + +const ( + testUserID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + testDeviceID = "0123456789abcdef0123456789abcdef" ) func TestIssueVerifyRoundtrip(t *testing.T) { @@ -11,8 +19,7 @@ func TestIssueVerifyRoundtrip(t *testing.T) { t.Fatalf("NewManager: %v", err) } - deviceID := "0123456789abcdef0123456789abcdef" - signed, err := m.Issue(deviceID) + signed, err := m.Issue(testUserID, testDeviceID) if err != nil { t.Fatalf("Issue: %v", err) } @@ -21,18 +28,33 @@ func TestIssueVerifyRoundtrip(t *testing.T) { if err != nil { t.Fatalf("Verify: %v", err) } - if got != deviceID { - t.Fatalf("Verify: got %q want %q", got, deviceID) + if got.UserID != testUserID { + 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) { - m, err := NewManager("test-secret") - if err != nil { - t.Fatalf("NewManager: %v", err) - } + m, _ := NewManager("test-secret") - signed, _ := m.Issue("0123456789abcdef0123456789abcdef") + signed, _ := m.Issue(testUserID, testDeviceID) parts := strings.Split(signed, ".") parts[len(parts)-1] = "nope" tampered := strings.Join(parts, ".") @@ -43,10 +65,7 @@ func TestVerifyRejectsTamperedToken(t *testing.T) { } func TestVerifyRejectsGarbage(t *testing.T) { - m, err := NewManager("test-secret") - if err != nil { - t.Fatalf("NewManager: %v", err) - } + m, _ := NewManager("test-secret") if _, err := m.Verify("not-a-token"); err == nil { t.Fatal("expected garbage to be rejected") } @@ -56,8 +75,23 @@ func TestDifferentSecretRejectsToken(t *testing.T) { a, _ := NewManager("secret-a") b, _ := NewManager("secret-b") - signed, _ := a.Issue("0123456789abcdef0123456789abcdef") + signed, _ := a.Issue(testUserID, testDeviceID) if _, err := b.Verify(signed); err == nil { 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") + } +} diff --git a/backend/pkg/passwd/passwd.go b/backend/pkg/passwd/passwd.go new file mode 100644 index 0000000..8df58a2 --- /dev/null +++ b/backend/pkg/passwd/passwd.go @@ -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$$. +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) +} diff --git a/backend/pkg/passwd/passwd_test.go b/backend/pkg/passwd/passwd_test.go new file mode 100644 index 0000000..7c40b38 --- /dev/null +++ b/backend/pkg/passwd/passwd_test.go @@ -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") + } +} diff --git a/backend/repository/devices.go b/backend/repository/devices.go index 295bd56..332f5ad 100644 --- a/backend/repository/devices.go +++ b/backend/repository/devices.go @@ -4,7 +4,8 @@ import ( "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 { DB *sql.DB } @@ -29,3 +30,13 @@ func (d *Devices) Exists(deviceID string) (bool, error) { } 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 +} diff --git a/backend/repository/repository.go b/backend/repository/repository.go index 588b0b3..e40c177 100644 --- a/backend/repository/repository.go +++ b/backend/repository/repository.go @@ -10,6 +10,7 @@ type Repository struct { Devices *Devices Operations *Operations OcrJobs *OcrJobs + Users *Users } func NewRepository(conn *sql.DB) *Repository { @@ -18,5 +19,6 @@ func NewRepository(conn *sql.DB) *Repository { Devices: &Devices{DB: conn}, Operations: &Operations{DB: conn}, OcrJobs: &OcrJobs{DB: conn}, + Users: &Users{DB: conn}, } } diff --git a/backend/repository/resources.go b/backend/repository/resources.go index 77cec02..1a3f79b 100644 --- a/backend/repository/resources.go +++ b/backend/repository/resources.go @@ -78,7 +78,7 @@ func (r *Resources) folderExists(ownerID, folderID string) (bool, error) { var exists int err := r.DB.QueryRow( `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, ).Scan(&exists) if err == sql.ErrNoRows { @@ -102,7 +102,7 @@ func (r *Resources) insert(ownerID, id, name, parentResourceID, resourceType str parentID = parentResourceID } _, 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)`, 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. func (r *Resources) ListFiles(ownerID, folderResourceID string, limit, offset int, sort, order string) ([]FileRow, int, error) { 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 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) { row := r.DB.QueryRow( `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, ) 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) { result, err := r.DB.Exec( `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, ) 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. func (r *Resources) SearchFiles(ownerID, q string, limit, offset int) ([]FileRow, int, error) { 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 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 err := r.DB.QueryRow( `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, ).Scan(&row.ID, &row.Name, &row.ParentID) if err == sql.ErrNoRows { @@ -257,7 +257,7 @@ func (r *Resources) MoveResource(ownerID, resourceID, parentResourceID string) e } result, err := r.DB.Exec( `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, ) 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 { result, err := r.DB.Exec( `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, ) 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 { _, err := r.DB.Exec( `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, ) return err @@ -314,7 +314,7 @@ func (r *Resources) SyncDelete(ownerID, resourceID string) error { func (r *Resources) ExistsOwner(ownerID, resourceID string) (bool, error) { var exists int 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, ).Scan(&exists) if err == sql.ErrNoRows { @@ -335,7 +335,7 @@ type OwnedRow struct { func (r *Resources) ListOwned(ownerID string, afterMs int64) ([]OwnedRow, error) { rows, err := r.DB.Query( `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 ORDER BY updated_at DESC LIMIT 10000`, @@ -360,7 +360,7 @@ func (r *Resources) ListOwned(ownerID string, afterMs int64) ([]OwnedRow, error) func (r *Resources) ListRootFolders(ownerID string) ([]FolderRow, error) { rows, err := r.DB.Query( `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`, ownerID, ) diff --git a/backend/repository/resources_test.go b/backend/repository/resources_test.go index a77ce2a..f4fbc1b 100644 --- a/backend/repository/resources_test.go +++ b/backend/repository/resources_test.go @@ -15,11 +15,16 @@ func newTestResources(t *testing.T) *Resources { return &Resources{DB: conn} } -func mustInsertDevice(t *testing.T, repo *Resources, deviceID string) { +func mustInsertUser(t *testing.T, repo *Resources, userID string) { t.Helper() - dev := &Devices{DB: repo.DB} - if err := dev.Upsert(deviceID); err != nil { - t.Fatalf("upsert device: %v", err) + users := &Users{DB: repo.DB} + username := "user-" + userID[:8] + 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) owner := NewID() other := NewID() - mustInsertDevice(t, repo, owner) - mustInsertDevice(t, repo, other) + mustInsertUser(t, repo, owner) + mustInsertUser(t, repo, other) folderID := NewID() 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 { - 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) @@ -82,7 +87,7 @@ func TestCRUDScopedByOwner(t *testing.T) { func TestNameConflictAndUnknownFolder(t *testing.T) { repo := newTestResources(t) owner := NewID() - mustInsertDevice(t, repo, owner) + mustInsertUser(t, repo, owner) folderID := NewID() if err := repo.InsertFolder(owner, folderID, "Docs", ""); err != nil { diff --git a/backend/repository/users.go b/backend/repository/users.go new file mode 100644 index 0000000..6c8a6ba --- /dev/null +++ b/backend/repository/users.go @@ -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 +} diff --git a/backend/service/bootstrap.go b/backend/service/bootstrap.go new file mode 100644 index 0000000..fbdd473 --- /dev/null +++ b/backend/service/bootstrap.go @@ -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 +} diff --git a/backend/service/ocr.go b/backend/service/ocr.go index 452d133..dfdeb8c 100644 --- a/backend/service/ocr.go +++ b/backend/service/ocr.go @@ -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} } -// Create validates the file, queued the job, and starts processing. -func (o *Ocr) Create(deviceID, fileID string) (OcrJobDTO, error) { - if _, err := o.Repository.Resources.GetFile(deviceID, fileID); err != nil { +// Create valide la ressource (ownership par user), met le job en file et +// lance le traitement. Le job reste scopé par device (le service génère les +// 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 } jobID := repository.NewID() if err := o.Repository.OcrJobs.Create(jobID, deviceID, fileID); err != nil { return OcrJobDTO{}, err } - go o.process(deviceID, jobID, fileID) + go o.process(userID, deviceID, jobID, fileID) return OcrJobDTO{ID: jobID, Status: "queued"}, nil } @@ -51,12 +53,12 @@ func (o *Ocr) Get(deviceID, jobID string) (OcrJobDTO, error) { return toOcrJobDTO(row), nil } -func (o *Ocr) process(deviceID, jobID, fileID string) { +func (o *Ocr) process(userID, deviceID, jobID, fileID string) { ctx := context.Background() if err := o.Repository.OcrJobs.TouchProcessing(deviceID, jobID); err != nil { return } - path, err := o.physicalPath(deviceID, fileID) + path, err := o.physicalPath(userID, fileID) if err != nil { _ = o.Repository.OcrJobs.Fail(deviceID, jobID, "file not readable") return @@ -69,10 +71,10 @@ func (o *Ocr) process(deviceID, jobID, fileID string) { _ = o.Repository.OcrJobs.Complete(deviceID, jobID, text) } -// physicalPath resolves UPLOAD_DIR//. — the ext -// is chosen at upload time, so the actual file is matched by prefix. -func (o *Ocr) physicalPath(deviceID, fileID string) (string, error) { - matches, err := filepath.Glob(filepath.Join(o.UploadDir, deviceID, fileID+".*")) +// physicalPath résout UPLOAD_DIR//. — l'ext est +// choisi à l'upload, le fichier réel est retrouvé par préfixe. +func (o *Ocr) physicalPath(userID, fileID string) (string, error) { + matches, err := filepath.Glob(filepath.Join(o.UploadDir, userID, fileID+".*")) if err != nil { return "", err } diff --git a/backend/service/resources.go b/backend/service/resources.go index 8a05879..f9d7306 100644 --- a/backend/service/resources.go +++ b/backend/service/resources.go @@ -36,7 +36,7 @@ type FolderDTO struct { } // 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 { Repo *repository.Resources Repository *repository.Repository @@ -101,7 +101,7 @@ func (s *Resources) SearchFiles(ownerID, q string, page, pageSize int) ([]FileDT return files, total, nil } -// Upload persists the multipart-sourced file under UploadDir/ and +// Upload persists the multipart-sourced file under UploadDir/ and // records its metadata, returning the FileDTO. The physical file is removed // if metadata persistence fails (e.g. name conflict). func (s *Resources) Upload(ownerID string, file *multipart.FileHeader, folderID string) (FileDTO, error) { diff --git a/backend/service/sync.go b/backend/service/sync.go index a153d06..0e5b15f 100644 --- a/backend/service/sync.go +++ b/backend/service/sync.go @@ -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 { op := &ops[i] if err := validateSyncOp(op); err != 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 { return SyncResult{}, err } @@ -93,16 +96,16 @@ func (s *Resources) ApplyBatch(ownerID string, ops []SyncOperation) (SyncResult, continue } if ackOnlyOps[op.Operation] { - if err := s.recordApplied(ownerID, op); err != nil { + if err := s.recordApplied(deviceID, op); err != nil { return SyncResult{}, err } continue } - if err := s.applySyncOp(ownerID, op); err != nil { + if err := s.applySyncOp(userID, op); err != nil { code, message := classifySyncError(err) 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 } } @@ -230,9 +233,9 @@ type ResourcePermission struct { UpdatedAt int64 `json:"updatedAt"` } -// Snapshot returns the delta of effective permissions for the device since -// afterMs (epoch ms; 0 = all). V1 single-owner : toutes les ressources -// appartiennent au device appelant (effective_access = owner). +// Snapshot returns the delta of effective permissions for the user since +// afterMs (epoch ms; 0 = all). V1 : pas encore de partage entre users — toutes +// les ressources appartiennent au user appelant (effective_access = owner). func (s *Resources) Snapshot(ownerID string, afterMs int64) ([]ResourcePermission, error) { rows, err := s.Repo.ListOwned(ownerID, afterMs) if err != nil { diff --git a/docs/api-v1.md b/docs/api-v1.md index db5a426..f01f670 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -16,9 +16,13 @@ Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync) ## 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 ` (toutes les routes **sauf `/health`**), résolu en `device_id` par middleware. V1 : pas de comptes utilisateurs (`users.user_id` reste NULL sur `devices`). -- 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). -- **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. +- **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 ` (toutes les routes **sauf `/health`, `/devices`, `/auth/login`**). +- **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`). +- **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()`). ## 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 | |---|---|---|---|---| | 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/:id` | — | `FileDto` | `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). - Limite : `MAX_FILE_SIZE_MB` (défaut 50). Dépassement → 413 `{ "error": { "code": "FILE_TOO_LARGE", … } }`. -- Le fichier physique est stocké sous `UPLOAD_DIR//.` ; 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//.` ; 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 - `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`. - 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) @@ -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). -- **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. - 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. @@ -103,7 +110,7 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?: ### 6.2 Snapshot — `GET /sync/permissions?after=` -- 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 type ResourcePermission = { @@ -111,7 +118,7 @@ type ResourcePermission = { resourceType: 'folder' | 'file'; effectiveAccess: 'viewer' | 'commenter' | 'editor' | 'owner'; inherit: boolean; - ownerId: string | null; // device ownership si applicable + ownerId: string | null; // ownership USER si applicable sharedById: string | null; expiresAt: number | null; // ms epoch ; null = jamais 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`. 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**. - 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. - **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 -`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_` (fallback). Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé. \ No newline at end of file +- 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_` (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. \ No newline at end of file