From 76ceafa56cb91a10f86ec4328f16b9b6b3be826d Mon Sep 17 00:00:00 2001 From: m Date: Tue, 14 Jul 2026 09:47:55 +0200 Subject: [PATCH] add backend auth --- backend/cmd/server/main.go | 9 +- backend/go.mod | 6 +- backend/go.sum | 4 + backend/internal/auth/handler.go | 135 ++++++++++ backend/internal/auth/middleware.go | 41 +++ backend/internal/auth/service.go | 250 ++++++++++++++++++ backend/internal/config/config.go | 2 + backend/internal/db/auth.sql.go | 132 +++++++++ .../db/migrations/009_users_default.down.sql | 5 + .../db/migrations/009_users_default.up.sql | 1 + .../internal/db/migrations/010_auth.down.sql | 2 + .../internal/db/migrations/010_auth.up.sql | 19 ++ backend/internal/db/models.go | 17 ++ backend/internal/db/queries/auth.sql | 25 ++ backend/internal/handler/handler.go | 5 +- backend/internal/handler/router.go | 42 +-- mobile/App.tsx | 79 ++++-- mobile/api/client.ts | 64 ++++- mobile/api/secureStorage.ts | 30 +++ mobile/app.json | 5 +- mobile/app/file-edit.tsx | 108 ++++++-- mobile/app/login.tsx | 141 ++++++++++ mobile/app/register.tsx | 161 +++++++++++ mobile/constants/api.ts | 4 + mobile/contexts/AuthContext.tsx | 90 +++++++ mobile/package-lock.json | 10 + mobile/package.json | 1 + mobile/types/index.ts | 21 ++ 28 files changed, 1345 insertions(+), 64 deletions(-) create mode 100644 backend/internal/auth/handler.go create mode 100644 backend/internal/auth/middleware.go create mode 100644 backend/internal/auth/service.go create mode 100644 backend/internal/db/auth.sql.go create mode 100644 backend/internal/db/migrations/009_users_default.down.sql create mode 100644 backend/internal/db/migrations/009_users_default.up.sql create mode 100644 backend/internal/db/migrations/010_auth.down.sql create mode 100644 backend/internal/db/migrations/010_auth.up.sql create mode 100644 backend/internal/db/queries/auth.sql create mode 100644 mobile/api/secureStorage.ts create mode 100644 mobile/app/login.tsx create mode 100644 mobile/app/register.tsx create mode 100644 mobile/contexts/AuthContext.tsx diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 8d3581f..0c5e8fd 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "github.com/joho/godotenv" + "github.com/vaultdrop/backend/internal/auth" "github.com/vaultdrop/backend/internal/config" "github.com/vaultdrop/backend/internal/db" "github.com/vaultdrop/backend/internal/handler" @@ -32,14 +33,18 @@ func main() { fileSvc := service.NewFileService(queries, cfg) ocrSvc := service.NewOCRService(cfg, fileSvc) urlSvc := service.NewURLService(cfg.HMACSecret, cfg.ServerHost) + authSvc, err := auth.NewAuthService(queries, cfg) + if err != nil { + log.Fatalf("Failed to create auth service: %v", err) + } ocrSvc.Start() defer ocrSvc.Stop() - h := handler.New(fileSvc, ocrSvc, urlSvc) + h := handler.New(fileSvc, ocrSvc, urlSvc, auth.NewAuthHandler(authSvc)) r := gin.Default() - handler.SetupRoutes(r, h) + handler.SetupRoutes(r, h, authSvc) log.Printf("Server starting on port %s", cfg.Port) if err := r.Run(":" + cfg.Port); err != nil { diff --git a/backend/go.mod b/backend/go.mod index a8f9b3e..f7276f4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,14 +3,18 @@ module github.com/vaultdrop/backend go 1.26.4 require ( + aidanwoods.dev/go-paseto v1.6.0 github.com/gin-gonic/gin v1.12.0 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 github.com/lib/pq v1.12.3 + golang.org/x/crypto v0.52.0 ) require ( + aidanwoods.dev/go-result v0.3.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -22,7 +26,6 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -36,7 +39,6 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.52.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index efb26ce..e28ed09 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,7 @@ +aidanwoods.dev/go-paseto v1.6.0 h1:JA/PFk5lVsB/PakQGqnfmik/1tIHjE6F0UoPPoAO/nU= +aidanwoods.dev/go-paseto v1.6.0/go.mod h1:LdqkL0Z2mLL0kBWzmHVR1cGFniX+zyOweQmbNKYrDxQ= +aidanwoods.dev/go-result v0.3.1 h1:ee98hpohYUVYbI+pa6gUHTyoRerIudgjky/IPSowDXQ= +aidanwoods.dev/go-result v0.3.1/go.mod h1:GKnFg8p/BKulVD3wsfULiPhpPmrTWyiTIbz8EWuUqSk= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go new file mode 100644 index 0000000..35c0478 --- /dev/null +++ b/backend/internal/auth/handler.go @@ -0,0 +1,135 @@ +package auth + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/vaultdrop/backend/pkg/api" +) + +type AuthHandler struct { + auth *AuthService +} + +func NewAuthHandler(auth *AuthService) *AuthHandler { + return &AuthHandler{auth: auth} +} + +type registerRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type loginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type refreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +type logoutRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +type authResponse struct { + User UserResponse `json:"user"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +func (h *AuthHandler) Register(c *gin.Context) { + var req registerRequest + if err := c.ShouldBindJSON(&req); err != nil { + api.Error(c, http.StatusBadRequest, "INVALID_INPUT", "username and password are required") + return + } + + tokens, user, err := h.auth.Register(c.Request.Context(), req.Username, req.Password) + if err != nil { + switch { + case errors.Is(err, ErrUsernameTaken): + api.Error(c, http.StatusConflict, "USERNAME_TAKEN", "username already taken") + default: + api.Error(c, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) + } + return + } + + c.JSON(http.StatusCreated, authResponse{ + User: *user, + AccessToken: tokens.AccessToken, + RefreshToken: tokens.RefreshToken, + }) +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + api.Error(c, http.StatusBadRequest, "INVALID_INPUT", "username and password are required") + return + } + + tokens, user, err := h.auth.Login(c.Request.Context(), req.Username, req.Password) + if err != nil { + switch { + case errors.Is(err, ErrInvalidCredentials): + api.Error(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid username or password") + default: + api.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "something went wrong") + } + return + } + + c.JSON(http.StatusOK, authResponse{ + User: *user, + AccessToken: tokens.AccessToken, + RefreshToken: tokens.RefreshToken, + }) +} + +func (h *AuthHandler) Refresh(c *gin.Context) { + var req refreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + api.Error(c, http.StatusBadRequest, "INVALID_INPUT", "refresh_token is required") + return + } + + tokens, err := h.auth.Refresh(c.Request.Context(), req.RefreshToken) + if err != nil { + switch { + case errors.Is(err, ErrInvalidToken): + api.Error(c, http.StatusUnauthorized, "INVALID_TOKEN", "invalid or expired refresh token") + default: + api.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "something went wrong") + } + return + } + + c.JSON(http.StatusOK, tokenResponse{ + AccessToken: tokens.AccessToken, + RefreshToken: tokens.RefreshToken, + }) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + var req logoutRequest + if err := c.ShouldBindJSON(&req); err != nil { + api.Error(c, http.StatusBadRequest, "INVALID_INPUT", "refresh_token is required") + return + } + + if err := h.auth.Logout(c.Request.Context(), req.RefreshToken); err != nil { + api.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "something went wrong") + return + } + + c.JSON(http.StatusOK, gin.H{"message": "logged out"}) +} diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go new file mode 100644 index 0000000..e19911f --- /dev/null +++ b/backend/internal/auth/middleware.go @@ -0,0 +1,41 @@ +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +const UserIDKey = "userID" + +func (s *AuthService) RequireAuth() gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if header == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{"code": "MISSING_TOKEN", "message": "authorization header required"}, + }) + return + } + + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{"code": "INVALID_TOKEN", "message": "invalid authorization format"}, + }) + return + } + + claims, err := s.ValidateAccessToken(parts[1]) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{"code": "INVALID_TOKEN", "message": "invalid or expired token"}, + }) + return + } + + c.Set(UserIDKey, claims) + c.Next() + } +} diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go new file mode 100644 index 0000000..7daced1 --- /dev/null +++ b/backend/internal/auth/service.go @@ -0,0 +1,250 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "aidanwoods.dev/go-paseto" + "github.com/vaultdrop/backend/internal/config" + "github.com/vaultdrop/backend/internal/db" + "golang.org/x/crypto/argon2" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUsernameTaken = errors.New("username already taken") + ErrInvalidToken = errors.New("invalid or expired token") +) + +const ( + accessTokenTTL = 30 * time.Minute + refreshTokenTTL = 7 * 24 * time.Hour + saltLength = 16 +) + +type AuthService struct { + queries *db.Queries + key paseto.V4SymmetricKey + parser *paseto.Parser +} + +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type UserResponse struct { + ID string `json:"id"` + Username string `json:"username"` +} + +func NewAuthService(queries *db.Queries, cfg *config.Config) (*AuthService, error) { + key, err := paseto.V4SymmetricKeyFromHex(cfg.PASETOKey) + if err != nil { + return nil, fmt.Errorf("invalid paseto key: %w", err) + } + + parser := paseto.NewParser() + parser.AddRule(paseto.NotExpired()) + + return &AuthService{ + queries: queries, + key: key, + parser: &parser, + }, nil +} + +func (s *AuthService) Register(ctx context.Context, username, password string) (*TokenPair, *UserResponse, error) { + username = strings.TrimSpace(username) + if len(username) < 3 || len(username) > 30 { + return nil, nil, fmt.Errorf("username must be 3-30 characters") + } + if len(password) < 8 { + return nil, nil, fmt.Errorf("password must be at least 8 characters") + } + + existing, err := s.queries.GetUserByUsername(ctx, username) + if err == nil && existing.ID != "" { + return nil, nil, ErrUsernameTaken + } + + hash, err := hashPassword(password) + if err != nil { + return nil, nil, fmt.Errorf("hash password: %w", err) + } + + user, err := s.queries.CreateUser(ctx, db.CreateUserParams{ + Username: username, + PasswordHash: hash, + }) + if err != nil { + return nil, nil, fmt.Errorf("create user: %w", err) + } + + tokens, err := s.generateTokens(ctx, user.ID) + if err != nil { + return nil, nil, fmt.Errorf("generate tokens: %w", err) + } + + return tokens, &UserResponse{ID: user.ID, Username: user.Username}, nil +} + +func (s *AuthService) Login(ctx context.Context, username, password string) (*TokenPair, *UserResponse, error) { + user, err := s.queries.GetUserByUsername(ctx, strings.TrimSpace(username)) + if err != nil { + return nil, nil, ErrInvalidCredentials + } + + if !verifyPassword(password, user.PasswordHash) { + return nil, nil, ErrInvalidCredentials + } + + tokens, err := s.generateTokens(ctx, user.ID) + if err != nil { + return nil, nil, fmt.Errorf("generate tokens: %w", err) + } + + return tokens, &UserResponse{ID: user.ID, Username: user.Username}, nil +} + +func (s *AuthService) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) { + userID, err := s.validateRefreshToken(refreshToken) + if err != nil { + return nil, ErrInvalidToken + } + + tokenHash := hashToken(refreshToken) + stored, err := s.queries.GetRefreshToken(ctx, tokenHash) + if err != nil { + return nil, ErrInvalidToken + } + + if stored.UserID != userID { + return nil, ErrInvalidToken + } + + if err := s.queries.RevokeRefreshToken(ctx, tokenHash); err != nil { + return nil, fmt.Errorf("revoke refresh token: %w", err) + } + + tokens, err := s.generateTokens(ctx, userID) + if err != nil { + return nil, fmt.Errorf("generate tokens: %w", err) + } + + return tokens, nil +} + +func (s *AuthService) Logout(ctx context.Context, refreshToken string) error { + tokenHash := hashToken(refreshToken) + return s.queries.RevokeRefreshToken(ctx, tokenHash) +} + +func (s *AuthService) ValidateAccessToken(token string) (string, error) { + parsed, err := s.parser.ParseV4Local(s.key, token, nil) + if err != nil { + return "", ErrInvalidToken + } + + userID, err := parsed.GetString("user_id") + if err != nil || userID == "" { + return "", ErrInvalidToken + } + + return userID, nil +} + +func (s *AuthService) generateTokens(ctx context.Context, userID string) (*TokenPair, error) { + accessToken, err := s.createAccessToken(userID) + if err != nil { + return nil, err + } + + refreshToken, err := s.createRefreshToken(userID) + if err != nil { + return nil, err + } + + refreshHash := hashToken(refreshToken) + _, err = s.queries.CreateRefreshToken(ctx, db.CreateRefreshTokenParams{ + UserID: userID, + TokenHash: refreshHash, + ExpiresAt: time.Now().Add(refreshTokenTTL), + }) + if err != nil { + return nil, fmt.Errorf("store refresh token: %w", err) + } + + return &TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + }, nil +} + +func (s *AuthService) createAccessToken(userID string) (string, error) { + token := paseto.NewToken() + token.Set("user_id", userID) + token.SetExpiration(time.Now().Add(accessTokenTTL)) + return token.V4Encrypt(s.key, nil), nil +} + +func (s *AuthService) createRefreshToken(userID string) (string, error) { + token := paseto.NewToken() + token.Set("user_id", userID) + token.SetExpiration(time.Now().Add(refreshTokenTTL)) + return token.V4Encrypt(s.key, nil), nil +} + +func (s *AuthService) validateRefreshToken(token string) (string, error) { + parsed, err := s.parser.ParseV4Local(s.key, token, nil) + if err != nil { + return "", ErrInvalidToken + } + + userID, err := parsed.GetString("user_id") + if err != nil || userID == "" { + return "", ErrInvalidToken + } + + return userID, nil +} + +func hashPassword(password string) (string, error) { + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", err + } + hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) + return fmt.Sprintf("$argon2id$v=19$m=65536,t=1,p=4$%s$%s", + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(hash), + ), nil +} + +func verifyPassword(password, encodedHash string) bool { + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) + return sha256.Sum256(hash) == sha256.Sum256(expectedHash) +} + +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index c5711b5..852eaf7 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -9,6 +9,7 @@ type Config struct { UploadDir string HMACSecret string ServerHost string + PASETOKey string } func Load() *Config { @@ -19,6 +20,7 @@ func Load() *Config { UploadDir: envOr("UPLOAD_DIR", "./uploads"), HMACSecret: envOr("HMAC_SECRET", "thisismyrandomstring"), ServerHost: envOr("SERVER_HOST", "http://192.168.1.17:8080"), + PASETOKey: envOr("PASETO_KEY", "01234567890123456789012345678901234567890123456789012345678901234"), } } diff --git a/backend/internal/db/auth.sql.go b/backend/internal/db/auth.sql.go new file mode 100644 index 0000000..95b3853 --- /dev/null +++ b/backend/internal/db/auth.sql.go @@ -0,0 +1,132 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: auth.sql + +package db + +import ( + "context" + "time" +) + +const createRefreshToken = `-- name: CreateRefreshToken :one +INSERT INTO refresh_tokens (user_id, token_hash, expires_at) +VALUES ($1, $2, $3) +RETURNING id, user_id, token_hash, expires_at, revoked, created_at +` + +type CreateRefreshTokenParams struct { + UserID string `json:"user_id"` + TokenHash string `json:"token_hash"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) { + row := q.db.QueryRowContext(ctx, createRefreshToken, arg.UserID, arg.TokenHash, arg.ExpiresAt) + var i RefreshToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.TokenHash, + &i.ExpiresAt, + &i.Revoked, + &i.CreatedAt, + ) + return i, err +} + +const createUser = `-- name: CreateUser :one +INSERT INTO users (username, password_hash) +VALUES ($1, $2) +RETURNING id, username, password_hash, created_at, updated_at +` + +type CreateUserParams struct { + Username string `json:"username"` + PasswordHash string `json:"password_hash"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, arg.Username, arg.PasswordHash) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getRefreshToken = `-- name: GetRefreshToken :one +SELECT id, user_id, token_hash, expires_at, revoked, created_at FROM refresh_tokens +WHERE token_hash = $1 AND revoked = FALSE AND expires_at > NOW() +` + +func (q *Queries) GetRefreshToken(ctx context.Context, tokenHash string) (RefreshToken, error) { + row := q.db.QueryRowContext(ctx, getRefreshToken, tokenHash) + var i RefreshToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.TokenHash, + &i.ExpiresAt, + &i.Revoked, + &i.CreatedAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, username, password_hash, created_at, updated_at FROM users WHERE id = $1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = $1 +` + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByUsername, username) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const revokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec +UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = $1 +` + +func (q *Queries) RevokeAllUserRefreshTokens(ctx context.Context, userID string) error { + _, err := q.db.ExecContext(ctx, revokeAllUserRefreshTokens, userID) + return err +} + +const revokeRefreshToken = `-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens SET revoked = TRUE WHERE token_hash = $1 +` + +func (q *Queries) RevokeRefreshToken(ctx context.Context, tokenHash string) error { + _, err := q.db.ExecContext(ctx, revokeRefreshToken, tokenHash) + return err +} diff --git a/backend/internal/db/migrations/009_users_default.down.sql b/backend/internal/db/migrations/009_users_default.down.sql new file mode 100644 index 0000000..555e0ea --- /dev/null +++ b/backend/internal/db/migrations/009_users_default.down.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id TEXT NOT NULL DEFAULT gen_random_uuid(), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/backend/internal/db/migrations/009_users_default.up.sql b/backend/internal/db/migrations/009_users_default.up.sql new file mode 100644 index 0000000..365a210 --- /dev/null +++ b/backend/internal/db/migrations/009_users_default.up.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/backend/internal/db/migrations/010_auth.down.sql b/backend/internal/db/migrations/010_auth.down.sql new file mode 100644 index 0000000..7150d35 --- /dev/null +++ b/backend/internal/db/migrations/010_auth.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS refresh_tokens; +DROP TABLE IF EXISTS users; diff --git a/backend/internal/db/migrations/010_auth.up.sql b/backend/internal/db/migrations/010_auth.up.sql new file mode 100644 index 0000000..9b3ea73 --- /dev/null +++ b/backend/internal/db/migrations/010_auth.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE refresh_tokens ( + id TEXT NOT NULL DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); diff --git a/backend/internal/db/models.go b/backend/internal/db/models.go index a54cc01..26b9467 100644 --- a/backend/internal/db/models.go +++ b/backend/internal/db/models.go @@ -29,6 +29,15 @@ type FileTag struct { FileID sql.NullString `json:"file_id"` } +type RefreshToken struct { + ID string `json:"id"` + UserID string `json:"user_id"` + TokenHash string `json:"token_hash"` + ExpiresAt time.Time `json:"expires_at"` + Revoked bool `json:"revoked"` + CreatedAt time.Time `json:"created_at"` +} + type Tag struct { ID string `json:"id"` ParentTagID sql.NullString `json:"parent_tag_id"` @@ -37,3 +46,11 @@ type Tag struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/internal/db/queries/auth.sql b/backend/internal/db/queries/auth.sql new file mode 100644 index 0000000..cc88066 --- /dev/null +++ b/backend/internal/db/queries/auth.sql @@ -0,0 +1,25 @@ +-- name: CreateUser :one +INSERT INTO users (username, password_hash) +VALUES ($1, $2) +RETURNING *; + +-- name: GetUserByUsername :one +SELECT * FROM users WHERE username = $1; + +-- name: GetUserByID :one +SELECT * FROM users WHERE id = $1; + +-- name: CreateRefreshToken :one +INSERT INTO refresh_tokens (user_id, token_hash, expires_at) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetRefreshToken :one +SELECT * FROM refresh_tokens +WHERE token_hash = $1 AND revoked = FALSE AND expires_at > NOW(); + +-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens SET revoked = TRUE WHERE token_hash = $1; + +-- name: RevokeAllUserRefreshTokens :exec +UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = $1; diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 6bb1f8f..1bdd3dc 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -1,6 +1,7 @@ package handler import ( + "github.com/vaultdrop/backend/internal/auth" "github.com/vaultdrop/backend/internal/service" ) @@ -8,12 +9,14 @@ type Handler struct { File *FileHandler OCR *OCRHandler Health *HealthHandler + Auth *auth.AuthHandler } -func New(fileSvc *service.FileService, ocrSvc *service.OCRService, urlSvc *service.URLService) *Handler { +func New(fileSvc *service.FileService, ocrSvc *service.OCRService, urlSvc *service.URLService, authHandler *auth.AuthHandler) *Handler { return &Handler{ File: &FileHandler{files: fileSvc, urls: urlSvc, ocr: ocrSvc}, OCR: &OCRHandler{ocr: ocrSvc, files: fileSvc}, Health: &HealthHandler{ocr: ocrSvc}, + Auth: authHandler, } } diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go index 31d5e32..1a641bf 100644 --- a/backend/internal/handler/router.go +++ b/backend/internal/handler/router.go @@ -1,25 +1,37 @@ package handler -import "github.com/gin-gonic/gin" +import ( + "github.com/gin-gonic/gin" + "github.com/vaultdrop/backend/internal/auth" +) -func SetupRoutes(r *gin.Engine, h *Handler) { +func SetupRoutes(r *gin.Engine, h *Handler, authMiddleware *auth.AuthService) { api := r.Group("/api/v1") + // Public api.GET("/health", h.Health.Check) + api.POST("/auth/register", h.Auth.Register) + api.POST("/auth/login", h.Auth.Login) + api.POST("/auth/refresh", h.Auth.Refresh) + api.POST("/auth/logout", h.Auth.Logout) - api.GET("/files", h.File.List) - api.POST("/files/upload", h.File.Upload) - api.POST("/files/move", h.File.MoveFiles) - api.POST("/files/folders", h.File.CreateFolder) - api.GET("/files/folders", h.File.ListFolders) - api.GET("/files/folders/:id/files", h.File.ListFilesByParent) - api.GET("/files/download/:id", h.File.Download) - api.DELETE("/files/:id", h.File.Delete) - api.GET("/files/:id", h.File.Get) + // Protected + protected := api.Group("") + protected.Use(authMiddleware.RequireAuth()) - api.POST("/files/:id/tags", h.File.AddTags) - api.GET("/files/:id/tags", h.File.GetTags) + protected.GET("/files", h.File.List) + protected.POST("/files/upload", h.File.Upload) + protected.POST("/files/move", h.File.MoveFiles) + protected.POST("/files/folders", h.File.CreateFolder) + protected.GET("/files/folders", h.File.ListFolders) + protected.GET("/files/folders/:id/files", h.File.ListFilesByParent) + protected.GET("/files/download/:id", h.File.Download) + protected.DELETE("/files/:id", h.File.Delete) + protected.GET("/files/:id", h.File.Get) - api.POST("/ocr/jobs", h.OCR.CreateJob) - api.GET("/ocr/jobs/:id", h.OCR.GetJobStatus) + protected.POST("/files/:id/tags", h.File.AddTags) + protected.GET("/files/:id/tags", h.File.GetTags) + + protected.POST("/ocr/jobs", h.OCR.CreateJob) + protected.GET("/ocr/jobs/:id", h.OCR.GetJobStatus) } diff --git a/mobile/App.tsx b/mobile/App.tsx index 281df21..10ec5bd 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -1,8 +1,12 @@ import React from 'react'; import { StatusBar } from 'expo-status-bar'; +import { ActivityIndicator, View } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { LoginScreen } from './app/login'; +import { RegisterScreen } from './app/register'; import { HomeScreen } from './app/index'; import { UploadScreen } from './app/upload'; import { ScanScreen } from './app/scan'; @@ -16,32 +20,61 @@ import { FolderScreen } from './app/folder'; const Stack = createNativeStackNavigator(); const queryClient = new QueryClient(); +function AppNavigator() { + const { user, isLoading } = useAuth(); + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + {user ? ( + <> + + + + + + + + + + + ) : ( + <> + + + + )} + + + ); +} + export default function App() { return ( - - - - - - - - - - - - - - + + + + ); } diff --git a/mobile/api/client.ts b/mobile/api/client.ts index 92402be..13df5a2 100644 --- a/mobile/api/client.ts +++ b/mobile/api/client.ts @@ -1,26 +1,47 @@ import { API_BASE_URL, ENDPOINTS } from '../constants/api'; import { ApiError, HttpError } from '../types'; +import { tokenStorage } from './secureStorage'; class ApiClient { private baseUrl: string; + private accessToken: string | null = null; constructor(baseUrl: string) { this.baseUrl = baseUrl; } + setAccessToken(token: string | null) { + this.accessToken = token; + } + private async request( endpoint: string, - options: RequestInit = {} + options: RequestInit = {}, + isRetry = false ): Promise { const url = `${this.baseUrl}${endpoint}`; + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + if (this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + } + const response = await fetch(url, { ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, + headers, }); + if (response.status === 401 && !isRetry) { + const refreshed = await this.tryRefreshToken(); + if (refreshed) { + return this.request(endpoint, options, true); + } + throw new HttpError(401, 'Session expirée', 'UNAUTHORIZED'); + } + if (!response.ok) { let message = 'Request failed'; let code: string | undefined; @@ -35,6 +56,39 @@ class ApiClient { return response.json(); } + private async tryRefreshToken(): Promise { + try { + const refreshToken = await tokenStorage.getRefreshToken(); + if (!refreshToken) return false; + + const url = `${this.baseUrl}${ENDPOINTS.AUTH_REFRESH}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!response.ok) { + await this.clearAuth(); + return false; + } + + const data = await response.json(); + this.accessToken = data.access_token; + await tokenStorage.setRefreshToken(data.refresh_token); + return true; + } catch { + await this.clearAuth(); + return false; + } + } + + private async clearAuth() { + this.accessToken = null; + await tokenStorage.deleteRefreshToken(); + await tokenStorage.deleteUser(); + } + async get(endpoint: string): Promise { return this.request(endpoint); } diff --git a/mobile/api/secureStorage.ts b/mobile/api/secureStorage.ts new file mode 100644 index 0000000..4f84240 --- /dev/null +++ b/mobile/api/secureStorage.ts @@ -0,0 +1,30 @@ +import * as SecureStore from 'expo-secure-store'; + +const REFRESH_KEY = 'vaultdrop_refresh_token'; +const USER_KEY = 'vaultdrop_user'; + +export const tokenStorage = { + async getRefreshToken(): Promise { + return SecureStore.getItemAsync(REFRESH_KEY); + }, + + async setRefreshToken(token: string): Promise { + await SecureStore.setItemAsync(REFRESH_KEY, token); + }, + + async deleteRefreshToken(): Promise { + await SecureStore.deleteItemAsync(REFRESH_KEY); + }, + + async getUser(): Promise { + return SecureStore.getItemAsync(USER_KEY); + }, + + async setUser(user: string): Promise { + await SecureStore.setItemAsync(USER_KEY, user); + }, + + async deleteUser(): Promise { + await SecureStore.deleteItemAsync(USER_KEY); + }, +}; diff --git a/mobile/app.json b/mobile/app.json index 40b1782..e523c26 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -31,6 +31,9 @@ }, "web": { "favicon": "./assets/favicon.png" - } + }, + "plugins": [ + "expo-secure-store" + ] } } diff --git a/mobile/app/file-edit.tsx b/mobile/app/file-edit.tsx index 6ef59f3..ce7966e 100644 --- a/mobile/app/file-edit.tsx +++ b/mobile/app/file-edit.tsx @@ -30,9 +30,11 @@ type FileEditRouteParams = { interface FileEditItemProps { fileId: string; + selected: boolean; + onPress: () => void; } -function FileEditItem({ fileId }: FileEditItemProps) { +function FileEditItem({ fileId, selected, onPress }: FileEditItemProps) { const { data: fileData, isLoading: fileLoading } = useFile(fileId); const { data: imageData, isLoading: imageLoading } = useFileImage(fileId); const file = fileData as any; @@ -41,14 +43,14 @@ function FileEditItem({ fileId }: FileEditItemProps) { if (isLoading) { return ( - + - + ); } return ( - + {uri && file?.mimeType?.startsWith('image/') ? ( ) : ( @@ -58,7 +60,14 @@ function FileEditItem({ fileId }: FileEditItemProps) { size={ITEM_SIZE} /> )} - + {selected && ( + + + + + + )} + ); } @@ -74,6 +83,26 @@ export function FileEditScreen() { const [tagInput, setTagInput] = useState(''); const [pendingTags, setPendingTags] = useState([]); const [uploading, setUploading] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const hasSelection = selectedIds.size > 0; + const targetIds = hasSelection ? Array.from(selectedIds) : fileIds; + + const toggleSelection = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const toggleSelectAll = useCallback(() => { + setSelectedIds((prev) => { + if (prev.size === fileIds.length) return new Set(); + return new Set(fileIds); + }); + }, [fileIds]); const handleAddTag = () => { const tag = tagInput.trim().toLowerCase(); @@ -88,21 +117,21 @@ export function FileEditScreen() { const handleApplyTags = useCallback(async () => { if (pendingTags.length === 0) return; - for (const fileId of fileIds) { + for (const fileId of targetIds) { await addTags.mutateAsync({ fileId, tags: pendingTags }); } Alert.alert('Succès', `${pendingTags.length} tag${pendingTags.length > 1 ? 's' : ''} ajouté${pendingTags.length > 1 ? 's' : ''}`); setPendingTags([]); - }, [pendingTags, fileIds, addTags]); + }, [pendingTags, targetIds, addTags]); const handleGeneratePdf = useCallback(async () => { - if (fileIds.length === 0) return; + if (targetIds.length === 0) return; setUploading(true); try { const imageUris: { uri: string }[] = []; - for (const fileId of fileIds) { + for (const fileId of targetIds) { const response = await fetch(`${process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.1.17:8080/api/v1'}/files/${fileId}`); const data = await response.json(); const url = data?.data?.url; @@ -152,17 +181,26 @@ export function FileEditScreen() { } finally { setUploading(false); } - }, [fileIds, generatePdf, upload, navigation]); + }, [targetIds, generatePdf, upload, navigation]); const isLoading = generating || uploading; return ( - Édition - - {fileIds.length} fichier{fileIds.length > 1 ? 's' : ''} sélectionné{fileIds.length > 1 ? 's' : ''} - + + + Édition + + {hasSelection + ? `${selectedIds.size} sélectionné${selectedIds.size > 1 ? 's' : ''} / ${fileIds.length}` + : `${fileIds.length} fichier${fileIds.length > 1 ? 's' : ''}`} + + + + {hasSelection && selectedIds.size === fileIds.length ? 'Tout' : 'Tout'} + + item} contentContainerStyle={styles.grid} columnWrapperStyle={styles.gridRow} - renderItem={({ item }) => } + renderItem={({ item }) => ( + toggleSelection(item)} + /> + )} /> @@ -243,6 +287,11 @@ const styles = StyleSheet.create({ borderBottomWidth: 1, borderBottomColor: '#e0e0e0', }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, title: { fontSize: 20, fontWeight: '700', @@ -252,6 +301,17 @@ const styles = StyleSheet.create({ fontSize: 14, color: '#666', }, + selectAllBtn: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + backgroundColor: '#E3F2FD', + }, + selectAllText: { + fontSize: 13, + fontWeight: '600', + color: '#1976D2', + }, grid: { padding: 16, }, @@ -270,6 +330,24 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + selectedOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'flex-start', + alignItems: 'flex-end', + padding: 4, + }, + checkCircle: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#1976D2', + justifyContent: 'center', + alignItems: 'center', + }, thumb: { width: '100%', height: '100%', diff --git a/mobile/app/login.tsx b/mobile/app/login.tsx new file mode 100644 index 0000000..d53b99a --- /dev/null +++ b/mobile/app/login.tsx @@ -0,0 +1,141 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + Alert, + KeyboardAvoidingView, + Platform, + ActivityIndicator, +} from 'react-native'; +import { useAuth } from '../contexts/AuthContext'; + +export function LoginScreen({ navigation }: any) { + const { login } = useAuth(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleLogin() { + if (!username.trim() || !password) { + Alert.alert('Erreur', 'Veuillez remplir tous les champs'); + return; + } + + setLoading(true); + try { + await login(username.trim(), password); + } catch (error: any) { + Alert.alert('Erreur', error.message || 'Connexion échouée'); + } finally { + setLoading(false); + } + } + + return ( + + + Dot. + Connectez-vous à votre compte + + + + + + + {loading ? ( + + ) : ( + Se connecter + )} + + + navigation.navigate('Register')} + disabled={loading} + > + Pas de compte ? S'inscrire + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + inner: { + flex: 1, + justifyContent: 'center', + paddingHorizontal: 32, + }, + title: { + fontSize: 32, + fontWeight: 'bold', + textAlign: 'center', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#666', + textAlign: 'center', + marginBottom: 32, + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 16, + fontSize: 16, + marginBottom: 16, + }, + button: { + backgroundColor: '#000', + borderRadius: 8, + padding: 16, + alignItems: 'center', + marginBottom: 16, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + linkButton: { + alignItems: 'center', + }, + linkText: { + color: '#000', + fontSize: 14, + }, +}); diff --git a/mobile/app/register.tsx b/mobile/app/register.tsx new file mode 100644 index 0000000..ada2208 --- /dev/null +++ b/mobile/app/register.tsx @@ -0,0 +1,161 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + Alert, + KeyboardAvoidingView, + Platform, + ActivityIndicator, +} from 'react-native'; +import { useAuth } from '../contexts/AuthContext'; + +export function RegisterScreen({ navigation }: any) { + const { register } = useAuth(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleRegister() { + if (!username.trim() || !password || !confirmPassword) { + Alert.alert('Erreur', 'Veuillez remplir tous les champs'); + return; + } + + if (password !== confirmPassword) { + Alert.alert('Erreur', 'Les mots de passe ne correspondent pas'); + return; + } + + if (password.length < 8) { + Alert.alert('Erreur', 'Le mot de passe doit contenir au moins 8 caractères'); + return; + } + + setLoading(true); + try { + await register(username.trim(), password); + } catch (error: any) { + Alert.alert('Erreur', error.message || "Inscription échouée"); + } finally { + setLoading(false); + } + } + + return ( + + + Dot. + Créez votre compte + + + + + + + + + {loading ? ( + + ) : ( + S'inscrire + )} + + + navigation.navigate('Login')} + disabled={loading} + > + Déjà un compte ? Se connecter + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + inner: { + flex: 1, + justifyContent: 'center', + paddingHorizontal: 32, + }, + title: { + fontSize: 32, + fontWeight: 'bold', + textAlign: 'center', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#666', + textAlign: 'center', + marginBottom: 32, + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 16, + fontSize: 16, + marginBottom: 16, + }, + button: { + backgroundColor: '#000', + borderRadius: 8, + padding: 16, + alignItems: 'center', + marginBottom: 16, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + linkButton: { + alignItems: 'center', + }, + linkText: { + color: '#000', + fontSize: 14, + }, +}); diff --git a/mobile/constants/api.ts b/mobile/constants/api.ts index 4d55977..589dc51 100644 --- a/mobile/constants/api.ts +++ b/mobile/constants/api.ts @@ -9,4 +9,8 @@ export const ENDPOINTS = { FOLDERS: '/files/folders', OCR_JOBS: '/ocr/jobs', HEALTH: '/health', + AUTH_LOGIN: '/auth/login', + AUTH_REGISTER: '/auth/register', + AUTH_REFRESH: '/auth/refresh', + AUTH_LOGOUT: '/auth/logout', } as const; diff --git a/mobile/contexts/AuthContext.tsx b/mobile/contexts/AuthContext.tsx new file mode 100644 index 0000000..b1fe596 --- /dev/null +++ b/mobile/contexts/AuthContext.tsx @@ -0,0 +1,90 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { apiClient } from '../api/client'; +import { tokenStorage } from '../api/secureStorage'; +import { ENDPOINTS } from '../constants/api'; +import { AuthResponse, User } from '../types'; + +interface AuthContextType { + user: User | null; + isLoading: boolean; + login: (username: string, password: string) => Promise; + register: (username: string, password: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + loadUser(); + }, []); + + async function loadUser() { + try { + const stored = await tokenStorage.getUser(); + if (stored) { + const userData = JSON.parse(stored) as User; + setUser(userData); + } + } catch { + await tokenStorage.deleteUser(); + } finally { + setIsLoading(false); + } + } + + async function login(username: string, password: string) { + const response = await apiClient.post(ENDPOINTS.AUTH_LOGIN, { + username, + password, + }); + + apiClient.setAccessToken(response.access_token); + await tokenStorage.setRefreshToken(response.refresh_token); + await tokenStorage.setUser(JSON.stringify(response.user)); + setUser(response.user); + } + + async function register(username: string, password: string) { + const response = await apiClient.post(ENDPOINTS.AUTH_REGISTER, { + username, + password, + }); + + apiClient.setAccessToken(response.access_token); + await tokenStorage.setRefreshToken(response.refresh_token); + await tokenStorage.setUser(JSON.stringify(response.user)); + setUser(response.user); + } + + async function logout() { + try { + const refreshToken = await tokenStorage.getRefreshToken(); + if (refreshToken) { + await apiClient.post(ENDPOINTS.AUTH_LOGOUT, { refresh_token: refreshToken }); + } + } catch {} + + apiClient.setAccessToken(null); + await tokenStorage.deleteRefreshToken(); + await tokenStorage.deleteUser(); + setUser(null); + } + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 2d67655..45cdffd 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -17,6 +17,7 @@ "expo-file-system": "~57.0.0", "expo-image-picker": "~57.0.2", "expo-print": "~57.0.0", + "expo-secure-store": "~57.0.0", "expo-status-bar": "~57.0.0", "react": "19.2.3", "react-native": "0.86.0", @@ -2924,6 +2925,15 @@ "react-native": "*" } }, + "node_modules/expo-secure-store": { + "version": "57.0.0", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.0.tgz", + "integrity": "sha512-vkP16rhW7b4bljW5BC4kKXBpNxQ0O1E9SpI5NIfh2biZnszLTpI/gUF4oBsvOY2nvkh7oXS2ERuUoA8cuS8FWQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "57.0.0", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index 71fbbcf..41c37d0 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -12,6 +12,7 @@ "expo-file-system": "~57.0.0", "expo-image-picker": "~57.0.2", "expo-print": "~57.0.0", + "expo-secure-store": "~57.0.0", "expo-status-bar": "~57.0.0", "react": "19.2.3", "react-native": "0.86.0", diff --git a/mobile/types/index.ts b/mobile/types/index.ts index d555765..78c9d77 100644 --- a/mobile/types/index.ts +++ b/mobile/types/index.ts @@ -83,3 +83,24 @@ export type Batch = { photos: CapturedPhoto[]; tags: string[]; }; + +export interface User { + id: string; + username: string; +} + +export interface AuthTokens { + access_token: string; + refresh_token: string; +} + +export interface AuthResponse { + user: User; + access_token: string; + refresh_token: string; +} + +export interface RefreshResponse { + access_token: string; + refresh_token: string; +}