Initial commit
This commit is contained in:
commit
6301c4f3b5
23 changed files with 1062 additions and 0 deletions
52
.air.toml
Normal file
52
.air.toml
Normal file
|
|
@ -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
|
||||
13
.env.example
Normal file
13
.env.example
Normal file
|
|
@ -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
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.env
|
||||
main
|
||||
tmp/
|
||||
build-errors.log
|
||||
72
README.md
Normal file
72
README.md
Normal file
|
|
@ -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
|
||||
56
cmd/main.go
Normal file
56
cmd/main.go
Normal file
|
|
@ -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
|
||||
}
|
||||
11
go.mod
Normal file
11
go.mod
Normal file
|
|
@ -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
|
||||
8
go.sum
Normal file
8
go.sum
Normal file
|
|
@ -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=
|
||||
41
internal/auth/password.go
Normal file
41
internal/auth/password.go
Normal file
|
|
@ -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
|
||||
}
|
||||
156
internal/auth/session.go
Normal file
156
internal/auth/session.go
Normal file
|
|
@ -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
|
||||
}
|
||||
101
internal/database/db.go
Normal file
101
internal/database/db.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
12
internal/database/models.go
Normal file
12
internal/database/models.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
32
internal/handlers/handler.go
Normal file
32
internal/handlers/handler.go
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
72
internal/handlers/login.go
Normal file
72
internal/handlers/login.go
Normal file
|
|
@ -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"))
|
||||
}
|
||||
160
internal/handlers/login_test.go
Normal file
160
internal/handlers/login_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
53
internal/handlers/middleware.go
Normal file
53
internal/handlers/middleware.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
42
internal/handlers/signup.go
Normal file
42
internal/handlers/signup.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
48
internal/handlers/user.go
Normal file
48
internal/handlers/user.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
25
internal/services/auth.go
Normal file
25
internal/services/auth.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
25
internal/services/user.go
Normal file
25
internal/services/user.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
42
migrations/00001_init.sql
Normal file
42
migrations/00001_init.sql
Normal file
|
|
@ -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;
|
||||
11
scripts/goose-create.sh
Executable file
11
scripts/goose-create.sh
Executable file
|
|
@ -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
|
||||
16
scripts/goose.sh
Executable file
16
scripts/goose.sh
Executable file
|
|
@ -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 "$@"
|
||||
10
sqlc.yaml
Normal file
10
sqlc.yaml
Normal file
|
|
@ -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"
|
||||
Loading…
Reference in a new issue