feat(server): add email verification and add a proper cli to the termbox binary
This commit is contained in:
parent
24ca498f62
commit
9404620b39
@ -2,4 +2,5 @@ DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=termbox
|
||||
DB_USER=your_username
|
||||
DB_PASSWORD=your_password
|
||||
DB_PASSWORD=your_password
|
||||
RESEND_API_KEY=your_resend_api_key
|
||||
6
Makefile
6
Makefile
@ -2,12 +2,16 @@ BINARY = termbox
|
||||
SRV_BINARY = termbox_server
|
||||
GO_ENV = GOOS=linux GOARCH=amd64
|
||||
|
||||
.PHONY: build clean
|
||||
.PHONY: build clean test run-server dev-server
|
||||
all: build
|
||||
|
||||
build: clean
|
||||
mkdir -p build
|
||||
go build -o build/${BINARY} cmd/termbox/main.go
|
||||
go build -o build/${SRV_BINARY} cmd/termbox_server/main.go
|
||||
|
||||
clean:
|
||||
rm -rf build
|
||||
|
||||
dev:
|
||||
go run cmd/termbox_server/main.go
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.keircn.com/keiran/termbox/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Hello, Termbox!")
|
||||
if err := cli.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,8 @@ func main() {
|
||||
db.InitDB()
|
||||
defer db.Close()
|
||||
|
||||
handlers.InitEmailService()
|
||||
|
||||
e := echo.New()
|
||||
|
||||
e.Use(middleware.Logger())
|
||||
@ -21,6 +23,7 @@ func main() {
|
||||
|
||||
e.GET("/", handlers.HandleRoot)
|
||||
e.POST("/auth/register", handlers.HandleRegister)
|
||||
e.POST("/auth/verify", handlers.HandleVerifyCode)
|
||||
e.POST("/auth/login", handlers.HandleLogin)
|
||||
|
||||
log.Fatal(e.Start(":8080"))
|
||||
|
||||
5
go.mod
5
go.mod
@ -6,22 +6,27 @@ require (
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
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
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
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
|
||||
)
|
||||
|
||||
12
go.sum
12
go.sum
@ -1,8 +1,11 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
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/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/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=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@ -24,6 +27,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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/resend/resend-go/v2 v2.22.0 h1:rx52hlFeyiu01Ie5PBLJRYkde9WNEHDnrg/oGgOGDzk=
|
||||
github.com/resend/resend-go/v2 v2.22.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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=
|
||||
@ -43,6 +53,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
|
||||
167
internal/cli/root.go
Normal file
167
internal/cli/root.go
Normal file
@ -0,0 +1,167 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const apiURL = "http://localhost:8080"
|
||||
|
||||
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 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.",
|
||||
Run: runRegister,
|
||||
}
|
||||
|
||||
var loginCmd = &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Login to your account",
|
||||
Long: "Login to your account with username and password.",
|
||||
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,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(registerCmd)
|
||||
rootCmd.AddCommand(loginCmd)
|
||||
rootCmd.AddCommand(verifyCmd)
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
func runRegister(cmd *cobra.Command, args []string) {
|
||||
fmt.Print("Username: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
username, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(username)
|
||||
|
||||
fmt.Print("Email: ")
|
||||
email, _ := reader.ReadString('\n')
|
||||
email = strings.TrimSpace(email)
|
||||
|
||||
fmt.Print("Password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading password: %v\n", err)
|
||||
return
|
||||
}
|
||||
password := string(passwordBytes)
|
||||
fmt.Println()
|
||||
|
||||
requestBody, _ := json.Marshal(map[string]string{
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
})
|
||||
|
||||
resp, err := http.Post(apiURL+"/auth/register", "application/json", bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
fmt.Printf("Error registering: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var response map[string]interface{}
|
||||
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.")
|
||||
} 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"])
|
||||
}
|
||||
}
|
||||
|
||||
func runLogin(cmd *cobra.Command, args []string) {
|
||||
fmt.Print("Username: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
username, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(username)
|
||||
|
||||
fmt.Print("Password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading password: %v\n", err)
|
||||
return
|
||||
}
|
||||
password := string(passwordBytes)
|
||||
fmt.Println()
|
||||
|
||||
requestBody, _ := json.Marshal(map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
})
|
||||
|
||||
resp, err := http.Post(apiURL+"/auth/login", "application/json", bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
fmt.Printf("Error logging in: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
user := response["user"].(map[string]interface{})
|
||||
fmt.Printf("✅ Login successful! Welcome back, %s!\n", user["username"])
|
||||
} else {
|
||||
fmt.Printf("❌ Login failed: %s\n", response["error"])
|
||||
}
|
||||
}
|
||||
@ -57,7 +57,18 @@ func CreateTables() error {
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_verified BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verification_codes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(id) ON DELETE CASCADE,
|
||||
code VARCHAR(6) NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@ -72,7 +83,6 @@ func CreateTables() error {
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func Close() {
|
||||
if Pool != nil {
|
||||
Pool.Close()
|
||||
|
||||
@ -1,7 +1,19 @@
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_verified BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE verification_codes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(id) ON DELETE CASCADE,
|
||||
code VARCHAR(6) NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
|
||||
@ -2,20 +2,26 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
@ -24,6 +30,35 @@ type LoginRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type VerifyCodeRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type VerificationCode struct {
|
||||
ID int `json:"id"`
|
||||
UserID int `json:"user_id"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Used bool `json:"used"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func GenerateVerificationCode() (string, error) {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const length = 6
|
||||
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result[i] = charset[num.Int64()]
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
func CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@ -32,9 +67,9 @@ func CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) {
|
||||
|
||||
var user User
|
||||
err = Pool.QueryRow(ctx,
|
||||
"INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username, created_at",
|
||||
req.Username, string(hashedPassword),
|
||||
).Scan(&user.ID, &user.Username, &user.CreatedAt)
|
||||
"INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id, username, email, is_verified, created_at",
|
||||
req.Username, strings.ToLower(req.Email), string(hashedPassword),
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &user.IsVerified, &user.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -43,12 +78,93 @@ func CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func CreateVerificationCode(ctx context.Context, userID int) (*VerificationCode, error) {
|
||||
code, err := GenerateVerificationCode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
|
||||
var verificationCode VerificationCode
|
||||
err = Pool.QueryRow(ctx,
|
||||
"INSERT INTO verification_codes (user_id, code, expires_at) VALUES ($1, $2, $3) RETURNING id, user_id, code, expires_at, used, created_at",
|
||||
userID, code, expiresAt,
|
||||
).Scan(&verificationCode.ID, &verificationCode.UserID, &verificationCode.Code, &verificationCode.ExpiresAt, &verificationCode.Used, &verificationCode.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &verificationCode, nil
|
||||
}
|
||||
|
||||
func VerifyCode(ctx context.Context, email, code string) error {
|
||||
tx, err := Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var userID int
|
||||
var codeID int
|
||||
var expiresAt time.Time
|
||||
var used bool
|
||||
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT u.id, vc.id, vc.expires_at, vc.used
|
||||
FROM users u
|
||||
JOIN verification_codes vc ON u.id = vc.user_id
|
||||
WHERE u.email = $1 AND vc.code = $2
|
||||
ORDER BY vc.created_at DESC
|
||||
LIMIT 1
|
||||
`, strings.ToLower(email), code).Scan(&userID, &codeID, &expiresAt, &used)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid verification code")
|
||||
}
|
||||
|
||||
if used {
|
||||
return fmt.Errorf("verification code already used")
|
||||
}
|
||||
|
||||
if time.Now().After(expiresAt) {
|
||||
return fmt.Errorf("verification code expired")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE verification_codes SET used = true WHERE id = $1", codeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET is_verified = true WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
var user User
|
||||
err := Pool.QueryRow(ctx,
|
||||
"SELECT id, username, password_hash, created_at FROM users WHERE username = $1",
|
||||
"SELECT id, username, email, is_verified, created_at FROM users WHERE username = $1",
|
||||
username,
|
||||
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.CreatedAt)
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &user.IsVerified, &user.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var user User
|
||||
err := Pool.QueryRow(ctx,
|
||||
"SELECT id, username, email, is_verified, created_at FROM users WHERE email = $1",
|
||||
strings.ToLower(email),
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &user.IsVerified, &user.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -58,15 +174,25 @@ func GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
}
|
||||
|
||||
func ValidateUserCredentials(ctx context.Context, username, password string) (*User, error) {
|
||||
user, err := GetUserByUsername(ctx, username)
|
||||
var user User
|
||||
var passwordHash string
|
||||
err := Pool.QueryRow(ctx,
|
||||
"SELECT id, username, email, password_hash, is_verified, created_at FROM users WHERE username = $1",
|
||||
username,
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &passwordHash, &user.IsVerified, &user.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
|
||||
if !user.IsVerified {
|
||||
return nil, fmt.Errorf("account not verified")
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
50
internal/email/service.go
Normal file
50
internal/email/service.go
Normal file
@ -0,0 +1,50 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/resend/resend-go/v2"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
client *resend.Client
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
apiKey := os.Getenv("RESEND_API_KEY")
|
||||
if apiKey == "" {
|
||||
panic("RESEND_API_KEY environment variable is required")
|
||||
}
|
||||
|
||||
client := resend.NewClient(apiKey)
|
||||
return &Service{client: client}
|
||||
}
|
||||
|
||||
func (s *Service) SendVerificationCode(ctx context.Context, email, username, code string) error {
|
||||
params := &resend.SendEmailRequest{
|
||||
From: "noreply@termbox.keircn.com",
|
||||
To: []string{email},
|
||||
Subject: "Verify your Termbox account",
|
||||
Html: fmt.Sprintf(`
|
||||
<h2>Welcome to Termbox, %s!</h2>
|
||||
<p>Please verify your account with the following code:</p>
|
||||
<h1 style="font-family: monospace; letter-spacing: 5px; color: #2563eb;">%s</h1>
|
||||
<p>This code will expire in 10 minutes.</p>
|
||||
<p>If you didn't create an account, please ignore this email.</p>
|
||||
`, username, code),
|
||||
Text: fmt.Sprintf(`
|
||||
Welcome to Termbox, %s!
|
||||
|
||||
Please verify your account with the following code: %s
|
||||
|
||||
This code will expire in 10 minutes.
|
||||
|
||||
If you didn't create an account, please ignore this email.
|
||||
`, username, code),
|
||||
}
|
||||
|
||||
_, err := s.client.Emails.Send(params)
|
||||
return err
|
||||
}
|
||||
@ -2,11 +2,19 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.keircn.com/keiran/termbox/internal/db"
|
||||
"git.keircn.com/keiran/termbox/internal/email"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
var emailService *email.Service
|
||||
|
||||
func InitEmailService() {
|
||||
emailService = email.NewService()
|
||||
}
|
||||
|
||||
func HandleRoot(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "Hello, World!")
|
||||
}
|
||||
@ -17,21 +25,52 @@ func HandleRegister(c echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
if req.Username == "" || req.Password == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Username and password are required"})
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Username, email, and password are required"})
|
||||
}
|
||||
|
||||
user, err := db.CreateUser(c.Request().Context(), req)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "Username already exists"})
|
||||
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "Username or email already exists"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create user: " + err.Error()})
|
||||
}
|
||||
|
||||
verificationCode, err := db.CreateVerificationCode(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create verification code"})
|
||||
}
|
||||
|
||||
err = emailService.SendVerificationCode(c.Request().Context(), user.Email, user.Username, verificationCode.Code)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to send verification email"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]any{
|
||||
"message": "User created successfully",
|
||||
"message": "User created successfully. Please check your email for verification code.",
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func HandleVerifyCode(c echo.Context) error {
|
||||
var req db.VerifyCodeRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
if req.Email == "" || req.Code == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Email and code are required"})
|
||||
}
|
||||
|
||||
err := db.VerifyCode(c.Request().Context(), req.Email, req.Code)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Account verified successfully"})
|
||||
}
|
||||
|
||||
func HandleLogin(c echo.Context) error {
|
||||
var req db.LoginRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
@ -44,7 +83,7 @@ func HandleLogin(c echo.Context) error {
|
||||
|
||||
user, err := db.ValidateUserCredentials(c.Request().Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid credentials"})
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid credentials or account not verified"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user