From 6301c4f3b57383c47b0a4151b21fd24850198c08 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 15 Aug 2026 23:57:52 +0300 Subject: [PATCH] Initial commit --- .air.toml | 52 +++++++++++ .env.example | 13 +++ .gitignore | 4 + README.md | 72 ++++++++++++++ cmd/main.go | 56 +++++++++++ go.mod | 11 +++ go.sum | 8 ++ internal/auth/password.go | 41 ++++++++ internal/auth/session.go | 156 +++++++++++++++++++++++++++++++ internal/database/db.go | 101 ++++++++++++++++++++ internal/database/models.go | 12 +++ internal/handlers/handler.go | 32 +++++++ internal/handlers/login.go | 72 ++++++++++++++ internal/handlers/login_test.go | 160 ++++++++++++++++++++++++++++++++ internal/handlers/middleware.go | 53 +++++++++++ internal/handlers/signup.go | 42 +++++++++ internal/handlers/user.go | 48 ++++++++++ internal/services/auth.go | 25 +++++ internal/services/user.go | 25 +++++ migrations/00001_init.sql | 42 +++++++++ scripts/goose-create.sh | 11 +++ scripts/goose.sh | 16 ++++ sqlc.yaml | 10 ++ 23 files changed, 1062 insertions(+) create mode 100644 .air.toml create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/password.go create mode 100644 internal/auth/session.go create mode 100644 internal/database/db.go create mode 100644 internal/database/models.go create mode 100644 internal/handlers/handler.go create mode 100644 internal/handlers/login.go create mode 100644 internal/handlers/login_test.go create mode 100644 internal/handlers/middleware.go create mode 100644 internal/handlers/signup.go create mode 100644 internal/handlers/user.go create mode 100644 internal/services/auth.go create mode 100644 internal/services/user.go create mode 100644 migrations/00001_init.sql create mode 100755 scripts/goose-create.sh create mode 100755 scripts/goose.sh create mode 100644 sqlc.yaml diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..5dbdb63 --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "migrations", "scripts"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b678e46 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=your_password +DB_NAME=backend_template +DB_SSL_MODE=disable +PORT=8080 + +# goose (./scripts/goose.sh) — keep in sync with the DB_* values above +GOOSE_DRIVER=postgres +GOOSE_DBSTRING=postgres://postgres:your_password@localhost:5432/backend_template?sslmode=disable +GOOSE_MIGRATION_DIR=./migrations +GOOSE_TABLE=public.goose_migrations \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a86262b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +main +tmp/ +build-errors.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bfa6cc --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Go Backend Template + +A simple Go backend with authentication using native `net/http` and PostgreSQL. + +## Features + +- Session-based authentication +- Argon2 password hashing +- PostgreSQL database +- CORS support +- Request logging middleware +- Native Go HTTP server (no external frameworks) + +## Requirements + +- Go 1.24+ +- PostgreSQL +- [goose](https://github.com/pressly/goose) for migrations, [air](https://github.com/air-verse/air) for live reload (optional): + ```bash + go install github.com/pressly/goose/v3/cmd/goose@latest + go install github.com/air-verse/air@latest + ``` + +## Setup + +1. Copy environment variables: + ```bash + cp .env.example .env + ``` + +2. Update `.env` with your database credentials (both the `DB_*` and `GOOSE_*` values). + +3. Install dependencies: + ```bash + go mod tidy + ``` + +4. Run database migrations: + ```bash + ./scripts/goose.sh up + ``` + +5. Run the server: + ```bash + air # live reload, or: + go run ./cmd + ``` + +## Migrations + +`goose.sh` loads `.env` and passes through to goose (`up`, `down`, `status`, ...). +Create a new migration with: + +```bash +./scripts/goose-create.sh add_widgets +``` + +## SQLC (optional) + +Query codegen is configured in `sqlc.yaml` (engine `postgresql`, schema read from +`migrations/`). Add `.sql` files under `internal/database/queries/` and run +`sqlc generate` to emit typed accessors into `internal/database/sqlc/`. + +## API Endpoints + +- `POST /login` - User login +- `GET /user` - Get user info (requires session cookie) + +## Database Schema + +- `user_auth` - User accounts with Argon2 password hashes +- `user_session` - User sessions with SHA-256 hashed token secrets \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..fcc9491 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "log" + "net/http" + "os" + + "go-backend/internal/database" + "go-backend/internal/handlers" + "go-backend/internal/services" + + "github.com/joho/godotenv" +) + +func main() { + // Load .env file + if err := godotenv.Load(); err != nil { + log.Println("No .env file found") + } + + // Connect to database + db, err := database.Connect() + if err != nil { + log.Fatal("Failed to connect to database:", err) + } + defer db.Close() + + userService := services.NewUserService(db) + authService := services.NewAuthService(db) + handler := handlers.NewHandler(userService, authService) + + // Create router + mux := http.NewServeMux() + + // Routes + mux.HandleFunc("/login", handler.LoginHandler) + mux.HandleFunc("/user", handler.UserHandler) + + // Apply middleware (CORS first, then logging) + muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux)) + + // Start server + port := getEnv("PORT", "8080") + log.Printf("Server starting on port %s", port) + + if err := http.ListenAndServe(":"+port, muxMiddleware); err != nil { + log.Fatal("Server failed to start:", err) + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..13289df --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module go-backend + +go 1.24.1 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + golang.org/x/crypto v0.40.0 +) + +require golang.org/x/sys v0.37.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0799455 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..9a2f185 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,41 @@ +package auth + +import ( + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +func VerifyPassword(hashedPassword, password string) bool { + // Parse the Argon2 hash format: $argon2id$v=19$m=65536,t=3,p=4$salt$hash + parts := strings.Split(hashedPassword, "$") + if len(parts) != 6 { + return false + } + + // Extract parameters + var memory, time, threads uint32 + var version int + + fmt.Sscanf(parts[2], "v=%d", &version) + fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads) + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + + hash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + + // Generate hash with the same parameters + derivedKey := argon2.IDKey([]byte(password), salt, time, memory, uint8(threads), uint32(len(hash))) + + // Compare hashes using constant time comparison + return subtle.ConstantTimeCompare(hash, derivedKey) == 1 +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..9a5de16 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,156 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "strings" + "time" +) + +type Session struct { + ID string + SecretHash []byte + CreatedAt time.Time + UserID string +} + +type SessionWithToken struct { + Session + Token string +} + +const sessionExpiresInSeconds = 60 * 60 * 24 // 1 day + +func generateSecureRandomString() (string, error) { + // Human readable alphabet (a-z, 0-9 without l, o, 0, 1 to avoid confusion) + alphabet := "abcdefghijklmnpqrstuvwxyz23456789" + + // Generate 24 bytes = 192 bits of entropy. + // We're only going to use 5 bits per byte so the total entropy will be 192 * 5 / 8 = 120 bits + bytes := make([]byte, 24) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + + var id strings.Builder + for _, b := range bytes { + // >> 3 "removes" the right-most 3 bits of the byte + id.WriteByte(alphabet[b>>3]) + } + return id.String(), nil +} + +func hashSecret(secret string) []byte { + hash := sha256.Sum256([]byte(secret)) + return hash[:] +} + +func CreateSession(db *sql.DB, userID string) (*SessionWithToken, error) { + id, err := generateSecureRandomString() + if err != nil { + return nil, err + } + + secret, err := generateSecureRandomString() + if err != nil { + return nil, err + } + + secretHash := hashSecret(secret) + token := id + "." + secret + now := time.Now() + + // Insert session into database + _, err = db.Exec( + "INSERT INTO user_session (id, secret_hash, user_id, created_at) VALUES ($1, $2, $3, $4)", + id, secretHash, userID, now.Unix(), + ) + if err != nil { + return nil, err + } + + return &SessionWithToken{ + Session: Session{ + ID: id, + SecretHash: secretHash, + CreatedAt: now, + UserID: userID, + }, + Token: token, + }, nil +} + +func ValidateSessionToken(db *sql.DB, token string) (*Session, error) { + tokenParts := strings.Split(token, ".") + if len(tokenParts) != 2 { + return nil, nil + } + + sessionID := tokenParts[0] + sessionSecret := tokenParts[1] + + session, err := getSession(db, sessionID) + if err != nil { + return nil, err + } + if session == nil { + return nil, nil + } + + tokenSecretHash := hashSecret(sessionSecret) + if subtle.ConstantTimeCompare(tokenSecretHash, session.SecretHash) != 1 { + return nil, nil + } + + return session, nil +} + +func getSession(db *sql.DB, sessionID string) (*Session, error) { + var session Session + var createdAtUnix int64 + + err := db.QueryRow( + "SELECT id, secret_hash, user_id, created_at FROM user_session WHERE id = $1", + sessionID, + ).Scan(&session.ID, &session.SecretHash, &session.UserID, &createdAtUnix) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + session.CreatedAt = time.Unix(createdAtUnix, 0) + + // Check expiration + if time.Since(session.CreatedAt).Seconds() >= sessionExpiresInSeconds { + _, err = db.Exec("DELETE FROM user_session WHERE id = $1", sessionID) + if err != nil { + return nil, err + } + return nil, nil + } + + return &session, nil +} + +func ParseCookies(cookieHeader string) map[string]string { + cookies := make(map[string]string) + if cookieHeader == "" { + return cookies + } + + pairs := strings.Split(cookieHeader, ";") + for _, pair := range pairs { + pair = strings.TrimSpace(pair) + parts := strings.SplitN(pair, "=", 2) + if len(parts) == 2 { + cookies[parts[0]] = parts[1] + } + } + return cookies +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..4f51baa --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,101 @@ +package database + +import ( + "database/sql" + "fmt" + "os" + "time" + + _ "github.com/lib/pq" +) + +func Connect() (*sql.DB, error) { + dbHost := getEnv("DB_HOST", "localhost") + dbPort := getEnv("DB_PORT", "5432") + dbUser := getEnv("DB_USER", "postgres") + dbPassword := getEnv("DB_PASSWORD", "") + dbName := getEnv("DB_NAME", "backend_template") + sslMode := getEnv("DB_SSL_MODE", "disable") + + connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", + dbHost, dbPort, dbUser, dbPassword, dbName, sslMode) + + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, err + } + + // Verify connection + if err = db.Ping(); err != nil { + return nil, err + } + + return db, nil +} + +func FindUserByID(db *sql.DB, id string) (*User, error) { + var user User + var createdAtUnix, updatedAtUnix int64 + + err := db.QueryRow(` + SELECT id, username, email, password_hash, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM user_auth + WHERE id = $1 + `, id).Scan( + &user.ID, &user.Username, &user.Email, &user.PasswordHash, + &createdAtUnix, &updatedAtUnix, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + user.CreatedAt = timeFromUnix(createdAtUnix) + user.UpdatedAt = timeFromUnix(updatedAtUnix) + + return &user, nil +} + +func FindUserByUsername(db *sql.DB, username string) (*User, error) { + var user User + var createdAtUnix, updatedAtUnix int64 + + err := db.QueryRow(` + SELECT id, username, email, password_hash, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM user_auth + WHERE username = $1 + `, username).Scan( + &user.ID, &user.Username, &user.Email, &user.PasswordHash, + &createdAtUnix, &updatedAtUnix, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + user.CreatedAt = timeFromUnix(createdAtUnix) + user.UpdatedAt = timeFromUnix(updatedAtUnix) + + return &user, nil +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func timeFromUnix(timestamp int64) time.Time { + return time.Unix(timestamp, 0) +} diff --git a/internal/database/models.go b/internal/database/models.go new file mode 100644 index 0000000..2d3b4d9 --- /dev/null +++ b/internal/database/models.go @@ -0,0 +1,12 @@ +package database + +import "time" + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + PasswordHash string `json:"-"` // Don't serialize password hash + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} \ No newline at end of file diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go new file mode 100644 index 0000000..44143de --- /dev/null +++ b/internal/handlers/handler.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "go-backend/internal/auth" + "go-backend/internal/database" +) + +// UserService and AuthService are defined here, on the consumer side, so +// handlers depend only on the methods they actually use and tests can swap +// in fakes without touching the services package. + +type UserService interface { + FindByID(id string) (*database.User, error) + FindByUsername(username string) (*database.User, error) +} + +type AuthService interface { + CreateSession(userID string) (*auth.SessionWithToken, error) + ValidateSessionToken(token string) (*auth.Session, error) +} + +type Handler struct { + users UserService + auth AuthService +} + +func NewHandler(users UserService, auth AuthService) *Handler { + return &Handler{ + users: users, + auth: auth, + } +} diff --git a/internal/handlers/login.go b/internal/handlers/login.go new file mode 100644 index 0000000..8b7625d --- /dev/null +++ b/internal/handlers/login.go @@ -0,0 +1,72 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + + "go-backend/internal/auth" +) + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req LoginRequest + + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&req) + if err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Username == "" || req.Password == "" { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + user, err := h.users.FindByUsername(req.Username) + if err != nil { + log.Printf("Error finding user: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if user == nil { + http.Error(w, "Invalid username", http.StatusBadRequest) + return + } + + if !auth.VerifyPassword(user.PasswordHash, req.Password) { + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } + + session, err := h.auth.CreateSession(user.ID) + if err != nil { + log.Printf("Error creating session: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + cookie := &http.Cookie{ + Name: "session", + Value: session.Token, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 86400, // 24 hours + Path: "/", + } + http.SetCookie(w, cookie) + + w.WriteHeader(http.StatusOK) + w.Write([]byte("Success")) +} diff --git a/internal/handlers/login_test.go b/internal/handlers/login_test.go new file mode 100644 index 0000000..cbde23b --- /dev/null +++ b/internal/handlers/login_test.go @@ -0,0 +1,160 @@ +package handlers + +import ( + "bytes" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "go-backend/internal/auth" + "go-backend/internal/database" + + "golang.org/x/crypto/argon2" +) + +// fakeUsers and fakeAuth satisfy the UserService and AuthService interfaces +// declared in handler.go. No database, no network, no real hashing work — +// each test controls exactly what the "service layer" returns. + +type fakeUsers struct { + user *database.User + err error +} + +func (f *fakeUsers) FindByUsername(username string) (*database.User, error) { + return f.user, f.err +} + +func (f *fakeUsers) FindByID(id string) (*database.User, error) { + return f.user, f.err +} + +type fakeAuth struct { + session *auth.SessionWithToken + err error +} + +func (f *fakeAuth) CreateSession(userID string) (*auth.SessionWithToken, error) { + return f.session, f.err +} + +func (f *fakeAuth) ValidateSessionToken(token string) (*auth.Session, error) { + return nil, nil +} + +// hashPassword builds a real Argon2id hash in the format auth.VerifyPassword +// expects, so the "correct password" test case exercises the real +// verification logic rather than a stub. +func hashPassword(password, salt string) string { + saltBytes := []byte(salt) + hash := argon2.IDKey([]byte(password), saltBytes, 3, 65536, 4, 32) + return "$argon2id$v=19$m=65536,t=3,p=4$" + + base64.RawStdEncoding.EncodeToString(saltBytes) + "$" + + base64.RawStdEncoding.EncodeToString(hash) +} + +func TestLoginHandler(t *testing.T) { + validUser := &database.User{ID: "user-1", Username: "alice", PasswordHash: hashPassword("correct-horse", "somesalt16bytes!")} + + tests := []struct { + name string + body string + users UserService + auth AuthService + wantStatus int + }{ + { + name: "successful login sets session cookie", + body: `{"username":"alice","password":"correct-horse"}`, + users: &fakeUsers{user: validUser}, + auth: &fakeAuth{session: &auth.SessionWithToken{Token: "abc.def"}}, + wantStatus: http.StatusOK, + }, + { + name: "wrong password rejected", + body: `{"username":"alice","password":"wrong-password"}`, + users: &fakeUsers{user: validUser}, + auth: &fakeAuth{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "unknown username rejected", + body: `{"username":"nobody","password":"whatever"}`, + users: &fakeUsers{user: nil}, + auth: &fakeAuth{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "missing fields rejected before hitting services", + body: `{"username":"","password":""}`, + users: &fakeUsers{err: errors.New("should never be called")}, + auth: &fakeAuth{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "user lookup failure returns 500", + body: `{"username":"alice","password":"correct-horse"}`, + users: &fakeUsers{err: errors.New("connection refused")}, + auth: &fakeAuth{}, + wantStatus: http.StatusInternalServerError, + }, + { + name: "session creation failure returns 500", + body: `{"username":"alice","password":"correct-horse"}`, + users: &fakeUsers{user: validUser}, + auth: &fakeAuth{err: errors.New("disk full")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHandler(tt.users, tt.auth) + + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBufferString(tt.body)) + w := httptest.NewRecorder() + + h.LoginHandler(w, req) + + if w.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d (body: %s)", w.Code, tt.wantStatus, w.Body.String()) + } + + if tt.wantStatus == http.StatusOK { + resp := w.Result() + cookies := resp.Cookies() + if len(cookies) != 1 || cookies[0].Name != "session" || cookies[0].Value != "abc.def" { + t.Fatalf("expected session cookie with token abc.def, got %+v", cookies) + } + } + }) + } +} + +func TestLoginHandlerRejectsInvalidJSON(t *testing.T) { + h := NewHandler(&fakeUsers{}, &fakeAuth{}) + + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBufferString(`not-json`)) + w := httptest.NewRecorder() + + h.LoginHandler(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestLoginHandlerRejectsWrongMethod(t *testing.T) { + h := NewHandler(&fakeUsers{}, &fakeAuth{}) + + req := httptest.NewRequest(http.MethodGet, "/login", nil) + w := httptest.NewRecorder() + + h.LoginHandler(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed) + } +} diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go new file mode 100644 index 0000000..3f75b25 --- /dev/null +++ b/internal/handlers/middleware.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "log" + "net/http" + "time" +) + +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Create a response writer wrapper to capture status code + wrapper := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + + next.ServeHTTP(wrapper, r) + + duration := time.Since(start) + timestamp := time.Now().Format(time.RFC3339) + + log.Printf("[%s] %s %s - %d (%v)", timestamp, r.Method, r.URL.Path, wrapper.statusCode, duration) + }) +} + +func CORSMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Max-Age", "86400") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} + +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} \ No newline at end of file diff --git a/internal/handlers/signup.go b/internal/handlers/signup.go new file mode 100644 index 0000000..7f2b00d --- /dev/null +++ b/internal/handlers/signup.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +type SignUpRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req SignUpRequest + + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&req) + if err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Username == "" || req.Password == "" { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + user, err := h.users.FindByUsername(req.Username) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + + if user != nil { + http.Error(w, "Username already taken", http.StatusBadRequest) + } + +} diff --git a/internal/handlers/user.go b/internal/handlers/user.go new file mode 100644 index 0000000..739c658 --- /dev/null +++ b/internal/handlers/user.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "go-backend/internal/auth" +) + +func (h *Handler) UserHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get session token from cookie + cookies := auth.ParseCookies(r.Header.Get("Cookie")) + sessionToken, exists := cookies["session"] + if !exists { + http.Error(w, "Unauthorized, no session token", http.StatusUnauthorized) + return + } + + // Validate session + session, err := h.auth.ValidateSessionToken(sessionToken) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if session == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get user info + user, err := h.users.FindByID(session.UserID) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if user == nil { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(user) +} diff --git a/internal/services/auth.go b/internal/services/auth.go new file mode 100644 index 0000000..b88a803 --- /dev/null +++ b/internal/services/auth.go @@ -0,0 +1,25 @@ +package services + +import ( + "database/sql" + + "go-backend/internal/auth" +) + +type AuthService struct { + db *sql.DB +} + +func NewAuthService(db *sql.DB) *AuthService { + return &AuthService{ + db: db, + } +} + +func (s *AuthService) CreateSession(userID string) (*auth.SessionWithToken, error) { + return auth.CreateSession(s.db, userID) +} + +func (s *AuthService) ValidateSessionToken(token string) (*auth.Session, error) { + return auth.ValidateSessionToken(s.db, token) +} diff --git a/internal/services/user.go b/internal/services/user.go new file mode 100644 index 0000000..94fd961 --- /dev/null +++ b/internal/services/user.go @@ -0,0 +1,25 @@ +package services + +import ( + "database/sql" + + "go-backend/internal/database" +) + +type UserService struct { + db *sql.DB +} + +func NewUserService(db *sql.DB) *UserService { + return &UserService{ + db: db, + } +} + +func (s *UserService) FindByID(id string) (*database.User, error) { + return database.FindUserByID(s.db, id) +} + +func (s *UserService) FindByUsername(username string) (*database.User, error) { + return database.FindUserByUsername(s.db, username) +} diff --git a/migrations/00001_init.sql b/migrations/00001_init.sql new file mode 100644 index 0000000..4f00251 --- /dev/null +++ b/migrations/00001_init.sql @@ -0,0 +1,42 @@ +-- +goose Up + +CREATE TABLE IF NOT EXISTS user_auth ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- created_at is Unix seconds, not a timestamp: internal/auth/session.go writes +-- now.Unix() and scans the column straight into an int64. +CREATE TABLE IF NOT EXISTS user_session ( + id TEXT PRIMARY KEY, + secret_hash BYTEA NOT NULL, + user_id TEXT NOT NULL REFERENCES user_auth (id) ON DELETE CASCADE, + created_at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_session_user_id ON user_session (user_id); + +-- +goose StatementBegin +CREATE FUNCTION set_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +CREATE TRIGGER user_auth_set_updated_at + BEFORE UPDATE ON user_auth + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- +goose Down + +DROP TRIGGER IF EXISTS user_auth_set_updated_at ON user_auth; +DROP FUNCTION IF EXISTS set_updated_at(); +DROP TABLE IF EXISTS user_session; +DROP TABLE IF EXISTS user_auth; diff --git a/scripts/goose-create.sh b/scripts/goose-create.sh new file mode 100755 index 0000000..40509b2 --- /dev/null +++ b/scripts/goose-create.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Create a new timestamped SQL migration in ./migrations +# Usage: ./scripts/goose-create.sh add_widgets +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +export GOOSE_DRIVER="${GOOSE_DRIVER:-postgres}" +export GOOSE_MIGRATION_DIR="${GOOSE_MIGRATION_DIR:-$SCRIPT_DIR/../migrations}" + +goose create -s "$@" sql diff --git a/scripts/goose.sh b/scripts/goose.sh new file mode 100755 index 0000000..4177ef4 --- /dev/null +++ b/scripts/goose.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Run goose against the Postgres database configured in .env +# Usage: ./scripts/goose.sh up | down | status | version +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +set -a +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../.env" +set +a + +export GOOSE_DRIVER="${GOOSE_DRIVER:-postgres}" +export GOOSE_MIGRATION_DIR="${GOOSE_MIGRATION_DIR:-$SCRIPT_DIR/../migrations}" + +goose "$@" diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..1d49e7c --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "internal/database/queries" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "internal/database/sqlc" + sql_package: "database/sql"