diff --git a/.env.example b/.env.example index 58daba7..40568e9 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,5 @@ DB_PORT=5432 DB_NAME=termbox DB_USER=your_username DB_PASSWORD=your_password -RESEND_API_KEY=your_resend_api_key \ No newline at end of file +RESEND_API_KEY=your_resend_api_key +JWT_SECRET=some_long_string_here diff --git a/go.mod b/go.mod index c26c317..07f5a7a 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module git.keircn.com/keiran/termbox go 1.24.5 require ( + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/jackc/pgx/v5 v5.7.5 github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.12.0 github.com/resend/resend-go/v2 v2.22.0 github.com/spf13/cobra v1.9.1 golang.org/x/crypto v0.37.0 + golang.org/x/term v0.31.0 ) require ( @@ -26,7 +28,6 @@ require ( golang.org/x/net v0.24.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect - golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.5.0 // indirect ) diff --git a/go.sum b/go.sum index 6504e3f..19f831c 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ 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/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/internal/cli/root.go b/internal/cli/root.go index 5e8c522..52dba4c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "path/filepath" "strings" "syscall" @@ -16,16 +17,26 @@ import ( const apiURL = "http://localhost:8080" +type Config struct { + Token string `json:"token"` +} + var rootCmd = &cobra.Command{ Use: "termbox", Short: "Termbox CLI - A secure messaging platform", Long: "Termbox CLI allows you to register, login, and send messages securely.", } +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Authentication commands", + Long: "Commands for user authentication including register, login, and logout.", +} + var registerCmd = &cobra.Command{ Use: "register", Short: "Register a new account", - Long: "Register a new account with username, email, and password. You'll need to verify your email.", + Long: "Register a new account with username, email, and password. Includes email verification.", Run: runRegister, } @@ -36,23 +47,66 @@ var loginCmd = &cobra.Command{ Run: runLogin, } -var verifyCmd = &cobra.Command{ - Use: "verify", - Short: "Verify your email address", - Long: "Verify your email address with the code sent to your email.", - Run: runVerify, +var logoutCmd = &cobra.Command{ + Use: "logout", + Short: "Logout from your account", + Long: "Clear stored authentication token.", + Run: runLogout, } func init() { - rootCmd.AddCommand(registerCmd) - rootCmd.AddCommand(loginCmd) - rootCmd.AddCommand(verifyCmd) + authCmd.AddCommand(registerCmd) + authCmd.AddCommand(loginCmd) + authCmd.AddCommand(logoutCmd) + rootCmd.AddCommand(authCmd) } func Execute() error { return rootCmd.Execute() } +func getConfigPath() string { + homeDir, _ := os.UserHomeDir() + return filepath.Join(homeDir, ".termbox", "config.json") +} + +func loadConfig() (*Config, error) { + configPath := getConfigPath() + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return &Config{}, nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var config Config + err = json.Unmarshal(data, &config) + if err != nil { + return nil, err + } + + return &config, nil +} + +func saveConfig(config *Config) error { + configPath := getConfigPath() + configDir := filepath.Dir(configPath) + + if err := os.MkdirAll(configDir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configPath, data, 0600) +} + func runRegister(cmd *cobra.Command, args []string) { fmt.Print("Username: ") reader := bufio.NewReader(os.Stdin) @@ -89,42 +143,34 @@ func runRegister(cmd *cobra.Command, args []string) { json.NewDecoder(resp.Body).Decode(&response) if resp.StatusCode == http.StatusCreated { - fmt.Printf("✅ Registration successful! Check your email (%s) for verification code.\n", email) - fmt.Println("Run 'termbox verify' to complete registration.") + fmt.Printf("Registration successful! Check your email (%s) for verification code.\n", email) + + fmt.Print("Verification code: ") + code, _ := reader.ReadString('\n') + code = strings.TrimSpace(code) + + verifyBody, _ := json.Marshal(map[string]string{ + "email": email, + "code": code, + }) + + verifyResp, err := http.Post(apiURL+"/auth/verify", "application/json", bytes.NewBuffer(verifyBody)) + if err != nil { + fmt.Printf("Error verifying: %v\n", err) + return + } + defer verifyResp.Body.Close() + + var verifyResponse map[string]interface{} + json.NewDecoder(verifyResp.Body).Decode(&verifyResponse) + + if verifyResp.StatusCode == http.StatusOK { + fmt.Println("Account verified successfully! You can now login.") + } else { + fmt.Printf("Verification failed: %s\n", verifyResponse["error"]) + } } else { - fmt.Printf("❌ Registration failed: %s\n", response["error"]) - } -} - -func runVerify(cmd *cobra.Command, args []string) { - fmt.Print("Email: ") - reader := bufio.NewReader(os.Stdin) - email, _ := reader.ReadString('\n') - email = strings.TrimSpace(email) - - fmt.Print("Verification code: ") - code, _ := reader.ReadString('\n') - code = strings.TrimSpace(code) - - requestBody, _ := json.Marshal(map[string]string{ - "email": email, - "code": code, - }) - - resp, err := http.Post(apiURL+"/auth/verify", "application/json", bytes.NewBuffer(requestBody)) - if err != nil { - fmt.Printf("Error verifying: %v\n", err) - return - } - defer resp.Body.Close() - - var response map[string]interface{} - json.NewDecoder(resp.Body).Decode(&response) - - if resp.StatusCode == http.StatusOK { - fmt.Println("✅ Account verified successfully! You can now login.") - } else { - fmt.Printf("❌ Verification failed: %s\n", response["error"]) + fmt.Printf("Registration failed: %s\n", response["error"]) } } @@ -160,8 +206,24 @@ func runLogin(cmd *cobra.Command, args []string) { if resp.StatusCode == http.StatusOK { user := response["user"].(map[string]interface{}) - fmt.Printf("✅ Login successful! Welcome back, %s!\n", user["username"]) + token := response["token"].(string) + + config := &Config{Token: token} + if err := saveConfig(config); err != nil { + fmt.Printf("Warning: Failed to save login token: %v\n", err) + } + + fmt.Printf("Login successful! Welcome back, %s!\n", user["username"]) } else { - fmt.Printf("❌ Login failed: %s\n", response["error"]) + fmt.Printf("Login failed: %s\n", response["error"]) } } + +func runLogout(cmd *cobra.Command, args []string) { + config := &Config{} + if err := saveConfig(config); err != nil { + fmt.Printf("Error clearing token: %v\n", err) + return + } + fmt.Println("Logged out successfully.") +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 370b21a..b60ae56 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -2,10 +2,13 @@ package handlers import ( "net/http" + "os" "strings" + "time" "git.keircn.com/keiran/termbox/internal/db" "git.keircn.com/keiran/termbox/internal/email" + "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" ) @@ -15,6 +18,17 @@ func InitEmailService() { emailService = email.NewService() } +func generateToken(userID int, username string) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "username": username, + "exp": time.Now().Add(time.Hour * 24 * 7).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(os.Getenv("JWT_SECRET"))) +} + func HandleRoot(c echo.Context) error { return c.String(http.StatusOK, "Hello, World!") } @@ -90,8 +104,14 @@ func HandleLogin(c echo.Context) error { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid credentials or account not verified"}) } + token, err := generateToken(user.ID, user.Username) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"}) + } + return c.JSON(http.StatusOK, map[string]any{ "message": "Login successful", "user": user, + "token": token, }) }