diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..97d9947 --- /dev/null +++ b/.env.sample @@ -0,0 +1,6 @@ +DB_NAME=textsender_auth_db +DB_USER=textsender_auth +DB_PASSWORD=yEDjWZCH2vdctjn! +DB_HOST=localhost +DB_PORT=5432 +DB_SSLMODE=disable diff --git a/.gitignore b/.gitignore index d1ac4d0..3a71d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /textsender-auth +.env +/vendor diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..33139e0 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "git.kundeng.us/phoenix/textsender-auth/internal/config" + "git.kundeng.us/phoenix/textsender-auth/internal/db" + "git.kundeng.us/phoenix/textsender-auth/internal/handler" + "git.kundeng.us/phoenix/textsender-auth/internal/handler/endpoint" + mdleware "git.kundeng.us/phoenix/textsender-auth/internal/middleware" + "git.kundeng.us/phoenix/textsender-auth/internal/model" +) + +func main() { + fmt.Println("textsender-auth") + + cfg := config.Load() + + db, err := db.NewDatabase(cfg.GetDBConnString()) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close() + + ctx := context.Background() + + if cfg.ResetDB { + log.Println("Resetting database") + if err := db.ResetDatabase(ctx); err != nil { + log.Fatalf("Failed to reset database: %v", err) + } + log.Println("Database reset completed. Exiting.") + return + } + + // Services + userStore := model.NewUserStore(db.Pool) + userHandler := handler.NewUserHandler(userStore) + + router := chi.NewRouter() + + router.Use(middleware.Logger) + router.Use(middleware.Recoverer) + router.Use(middleware.Timeout(60 * time.Second)) + router.Use(mdleware.JSONContentType) + + router.Post(endpoint.Register, userHandler.Register) + + // Start server + server := &http.Server{ + Addr: ":" + cfg.ServerPort, + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Graceful shutdown + go func() { + log.Printf("Server starting on port %s", cfg.ServerPort) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed to start: %v", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited") +} diff --git a/go.mod b/go.mod index d1d6998..f80be6a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,19 @@ module git.kundeng.us/phoenix/textsender-auth go 1.24.5 + +require ( + github.com/go-chi/chi/v5 v5.2.3 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.5 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.42.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..21e2d71 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..88ad34c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,107 @@ +package config + +import ( + "flag" + "fmt" + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + DBConnString string + ServerPort string + ResetDB bool +} + +type ConnectionInfo struct { + Username string + Password string + Database string + Host string + Port int + SslMode string +} + +const Port = "9080" + +func (ci ConnectionInfo) Parse() string { + return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", ci.Username, ci.Password, ci.Host, ci.Port, ci.Database, ci.SslMode) +} + +func Load() *Config { + resetDb := flag.Bool("reset-db", false, "Reset the database schema and exit") + port := flag.String("port", Port, "Server port") + flag.Parse() + + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + + unpackedConnString := unpackDBConnString() + dbConnString := unpackedConnString.Parse() + + return &Config{ + DBConnString: dbConnString, + ServerPort: *port, + ResetDB: *resetDb, + } +} + +func unpackDBConnString() (connInfo ConnectionInfo) { + username := os.Getenv("DB_USER") + password := os.Getenv("DB_PASSWORD") + host := os.Getenv("DB_HOST") + port := os.Getenv("DB_PORT") + database := os.Getenv("DB_NAME") + sslMode := os.Getenv("DB_SSLMODE") + + if username != "" { + connInfo.Username = username + } else { + connInfo.Username = "user" + } + + if password != "" { + connInfo.Password = password + } else { + connInfo.Password = "password" + } + + if host != "" { + connInfo.Host = host + } else { + connInfo.Host = "localhost" + } + + if port != "" { + num, err := strconv.Atoi(port) + if err != nil { + return + } + connInfo.Port = num + } else { + connInfo.Port = 5432 + } + + if database != "" { + connInfo.Database = database + } else { + connInfo.Database = "textsender_auth_db" + } + + if sslMode != "" { + connInfo.SslMode = sslMode + } else { + connInfo.SslMode = "disable" + } + + return +} + +func (c *Config) GetDBConnString() string { + return c.DBConnString +} diff --git a/internal/db/connection.go b/internal/db/connection.go new file mode 100644 index 0000000..91036dc --- /dev/null +++ b/internal/db/connection.go @@ -0,0 +1,113 @@ +// db/connection.go +package db + +import ( + "context" + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var Pool *pgxpool.Pool + +type Database struct { + Pool *pgxpool.Pool +} + +func NewDatabase(connString string) (*Database, error) { + ctx := context.Background() + // Parse the connection string and create pool configuration + config, err := pgxpool.ParseConfig(connString) + if err != nil { + return nil, fmt.Errorf("unable to parse DSN: %v", err) + } + + // Configure connection pool settings + config.MaxConns = 25 + config.MinConns = 5 + config.MaxConnLifetime = time.Hour + config.MaxConnIdleTime = 30 * time.Minute + config.HealthCheckPeriod = time.Minute + + // Configure connection timeouts + config.ConnConfig.ConnectTimeout = 10 * time.Second + config.ConnConfig.RuntimeParams["timezone"] = "UTC" + + // Create the connection pool + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("unable to create connection pool: %v", err) + } + + // Test the connection with a short timeout + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("unable to ping database: %v", err) + } + + log.Printf("Successfully connected to database") + return &Database{Pool: pool}, nil +} + +func (db *Database) Close() { + if db.Pool != nil { + db.Pool.Close() + log.Println("Database connection pool closed") + } +} + +// HealthCheck verifies the database connection is still alive +func (db *Database) HealthCheck(ctx context.Context) error { + return db.Pool.Ping(ctx) +} + +// GetPoolStats returns connection pool statistics +func (db *Database) GetPoolStats() string { + stats := db.Pool.Stat() + return fmt.Sprintf("TotalConns: %d, IdleConns: %d, AcquiredConns: %d", + stats.TotalConns(), stats.IdleConns(), stats.AcquiredConns()) +} + +func (db *Database) ResetDatabase(ctx context.Context) error { + tx, err := db.Pool.Begin(ctx) + if err != nil { + return fmt.Errorf("Transaction unable to begin: %v", err) + } + defer tx.Rollback(ctx) + + schemaContent, err := os.ReadFile("migrations/schema.sql") + if err != nil { + return fmt.Errorf("error reading schema file: %v", err) + } + + statements := strings.Split(string(schemaContent), ";") + + for _, stmt := range statements { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + + if strings.HasPrefix(stmt, "--") { + continue + } + + _, err := tx.Exec(ctx, stmt) + if err != nil { + return fmt.Errorf("error executing SQL statement: %v\nStatement: %s", err, stmt) + } + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("unable to commit transaction: %v", err) + } + + log.Println("Database schema applied successfully") + + return nil +} diff --git a/internal/handler/endpoint/endpoint.go b/internal/handler/endpoint/endpoint.go new file mode 100644 index 0000000..2ed3196 --- /dev/null +++ b/internal/handler/endpoint/endpoint.go @@ -0,0 +1,4 @@ +package endpoint + +// Endpoint for registering a user +const Register = "/api/v1/register" diff --git a/internal/handler/register.go b/internal/handler/register.go new file mode 100644 index 0000000..31ed41c --- /dev/null +++ b/internal/handler/register.go @@ -0,0 +1,108 @@ +package handler + +import "net/http" +import "encoding/json" +import "fmt" + +import ( + "github.com/google/uuid" + + "git.kundeng.us/phoenix/textsender-auth/internal/model" + "git.kundeng.us/phoenix/textsender-auth/internal/utility" +) + +type RegisterUser struct { + PhoneNumber string `json:"phone_number"` + Username string `json:"username"` + Password string `json:"password"` +} + +type RegisterResponseItem struct { + Id uuid.UUID `json:"id"` + PhoneNumber string `json:"phone_number"` + Username string `json:"username"` +} + +type RegisterResponse struct { + Message string `json:"message"` + Data []RegisterResponseItem `json:"data"` +} + +type UserHandler struct { + UserStore model.UserStore +} + +func NewUserHandler(userStore model.UserStore) *UserHandler { + return &UserHandler{UserStore: userStore} +} + +func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + user, err := extractUserFromReq(r) + if err != nil { + http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + + defer r.Body.Close() + + var statusCode int + resp := RegisterResponse{} + + fmt.Println("Username:", user.Username) + + ctx := r.Context() + + exists, err := h.UserStore.UserExists(ctx, user.Username) + if err != nil { + fmt.Printf("Error: %v", err) + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } + + if exists { + // User already exists + statusCode = http.StatusBadRequest + resp.Message = "Failure in creating User" + } else { + hashing := utility.HashMash{user.Password} + hashedPassword, err := hashing.HashPassword() + if err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + user.Password = hashedPassword + err := h.UserStore.CreateUser(ctx, &user) + if err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + resp.Message = "Successful" + statusCode = http.StatusOK + resp.Data = append(resp.Data, RegisterResponseItem{Id: user.Id, PhoneNumber: user.PhoneNumber, Username: user.Username}) + } + } + } + + respondWithJson(w, statusCode, &resp) +} + +func extractUserFromReq(r *http.Request) (user model.User, myError error) { + var usr RegisterUser + err := json.NewDecoder(r.Body).Decode(&usr) + if err != nil { + return user, err + } + + return model.User{PhoneNumber: usr.PhoneNumber, Username: usr.Username, Password: usr.Password}, nil +} + +func respondWithJson(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(data) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..86739a5 --- /dev/null +++ b/internal/middleware/middleware.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "log" + "net/http" + "time" +) + +func Logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start)) + }) +} + +func JSONContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..8b53790 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1 @@ +package model diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..62267c1 --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,120 @@ +package model + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type User struct { + Id uuid.UUID `json:"id"` + PhoneNumber string `json:"phone_number"` + Username string `json:"username"` + Password string `json:"password"` +} + +type UserStore interface { + CreateUser(ctx context.Context, user *User) error + GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) + GetUserByUsername(ctx context.Context, username string) (*User, error) + GetAllUsers(ctx context.Context) ([]*User, error) + UserExists(ctx context.Context, email string) (bool, error) +} + +type PGUserStore struct { + db *pgxpool.Pool +} + +func NewUserStore(db *pgxpool.Pool) *PGUserStore { + return &PGUserStore{db: db} +} + +func (s *PGUserStore) CreateUser(ctx context.Context, user *User) error { + query := ` + INSERT INTO users (phone_number, username, password) + VALUES ($1, $2, $3) + RETURNING id, phone_number, username + ` + + return s.db.QueryRow(ctx, query, user.PhoneNumber, user.Username, user.Password).Scan( + &user.Id, &user.PhoneNumber, &user.Username, + ) +} + +func (s *PGUserStore) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) { + query := `SELECT id, username, password, phone_number FROM users WHERE id = $1` + + var user User + err := s.db.QueryRow(ctx, query, id).Scan( + &user.Id, &user.Username, &user.Password, &user.PhoneNumber, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("getting user by ID: %w", err) + } + + return &user, nil +} + +func (s *PGUserStore) GetUserByUsername(ctx context.Context, username string) (*User, error) { + query := `SELECT id, username, password, phone_number FROM users WHERE username = $1` + + var user User + err := s.db.QueryRow(ctx, query, username).Scan( + &user.Id, &user.Username, &user.Password, &user.PhoneNumber, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("getting user by ID: %w", err) + } + + return &user, nil +} + +func (s *PGUserStore) GetAllUsers(ctx context.Context) ([]*User, error) { + query := `SELECT id, username, password, phone_number FROM users` + + rows, err := s.db.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("querying all users: %w", err) + } + defer rows.Close() + + var users []*User + for rows.Next() { + var user User + if err := rows.Scan( + &user.Id, &user.Username, &user.Password, &user.PhoneNumber, + ); err != nil { + return nil, fmt.Errorf("scanning user row: %w", err) + } + users = append(users, &user) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating user rows: %w", err) + } + + return users, nil +} + +func (s *PGUserStore) UserExists(ctx context.Context, username string) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)` + + var exists bool + err := s.db.QueryRow(ctx, query, username).Scan(&exists) + if err != nil { + return false, fmt.Errorf("checking if user exists: %w", err) + } + + return exists, nil +} diff --git a/internal/utility/hashing.go b/internal/utility/hashing.go new file mode 100644 index 0000000..8048137 --- /dev/null +++ b/internal/utility/hashing.go @@ -0,0 +1,24 @@ +package utility + + +import ( + "golang.org/x/crypto/bcrypt" +) + +type HashMash struct { + Password string +} + +func (h HashMash) HashPassword() (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(h.Password), bcrypt.DefaultCost) + return string(bytes), err +} + +func (h HashMash) CheckPasswordHash(password string, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func (h *HashMash) SetPassword(password string) { + h.Password = password +} diff --git a/main.go b/main.go deleted file mode 100644 index c7951f3..0000000 --- a/main.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import "net/http" -import "log" -import "fmt" - -// Port that the application runs on -const Port = 9080 - -func main() { - fmt.Println("textsender-auth") - - portListen := fmt.Sprintf(":%d", Port) - - log.Println("Starting server port:", Port) - log.Fatal(http.ListenAndServe(portListen, nil)) -} diff --git a/migrations/schema.sql b/migrations/schema.sql new file mode 100644 index 0000000..fdb9caf --- /dev/null +++ b/migrations/schema.sql @@ -0,0 +1,11 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +DROP TABLE IF EXISTS users CASCADE; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + phone_number TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL +); +