From af9c23c521c754e6967d8708d9cab7b78c3e3d8c Mon Sep 17 00:00:00 2001 From: Cody Couperus Date: Mon, 23 Feb 2026 14:34:19 -0500 Subject: [PATCH] start implementing auth --- config/config.go | 2 + frontend/src/App.tsx | 6 +- frontend/src/components/register-form.tsx | 89 +++++++++++++++++++++++ frontend/src/index.css | 20 +---- go.mod | 5 ++ go.sum | 4 + handlers/user.go | 62 ++++++++++++++++ main.go | 8 +- migrations/schema.sql | 10 ++- models/user.go | 27 +++++++ postgres/docker-compose.yaml | 8 ++ services/auth.go | 57 +++++++++++++++ services/database.go | 1 - services/user.go | 66 +++++++++++++++++ 14 files changed, 341 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/register-form.tsx create mode 100644 handlers/user.go create mode 100644 models/user.go create mode 100644 services/auth.go create mode 100644 services/user.go diff --git a/config/config.go b/config/config.go index 3177819..bbbf2e8 100644 --- a/config/config.go +++ b/config/config.go @@ -13,6 +13,7 @@ type Config struct { DBUser string DBPassword string DBName string + JWTSecret string } func GetEnv(key string) string { @@ -32,5 +33,6 @@ func Load() *Config { DBUser: GetEnv("DB_USER"), DBPassword: GetEnv("DB_PASSWORD"), DBName: GetEnv("DB_NAME"), + JWTSecret: GetEnv("JWT_SECRET"), } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3beadf..fb9563b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { ThemeProvider } from "@/components/theme-provider" import LatestTemperature from "@/components/latest-temperature" import TemperatureHistory from "@/components/temperature-history" import TemperaturePlot from "@/components/temperature-plot" +import RegisterForm from './components/register-form' // import { Button } from "@/components/ui/button" @@ -12,6 +13,9 @@ function App() { return (
+
+ +
@@ -21,7 +25,7 @@ function App() {
- ) + ) } export default App diff --git a/frontend/src/components/register-form.tsx b/frontend/src/components/register-form.tsx new file mode 100644 index 0000000..657c7b5 --- /dev/null +++ b/frontend/src/components/register-form.tsx @@ -0,0 +1,89 @@ +import { useState } from "react" +import { Button } from "@/components/ui/button" + +export default function RegisterForm() { + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [loading, setLoading] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + setSuccess(false) + + if (password !== confirmPassword) { + setError("Passwords do not match") + return + } + + if (password.length < 8) { + setError("Passwords must be at least 8 characters") + return + } + + setLoading(true) + try { + const res = await fetch("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }) + + if (!res.ok) { + const msg = await res.text() + setError(msg || "Registration failed") + return + } + + setSuccess(true) + setEmail("") + setPassword("") + setConfirmPassword("") + } + catch { + setError("Network error") + } + finally { + setLoading(false) + } + } + + return ( +
+

Register

+ {error &&

{error}

} + {success &&

Account created

} + setEmail(e.target.value)} + required + className="p-2 border rounded bg-background" + /> + setPassword(e.target.value)} + required + className="p-2 border rounded bg-background" + /> + setConfirmPassword(e.target.value)} + required + className="p-2 border rounded bg-background" + /> + + +
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index bd71600..8764fb1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -72,24 +72,6 @@ h1 { line-height: 1.1; } -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} @media (prefers-color-scheme: light) { :root { @@ -186,4 +168,4 @@ button:focus-visible { body { @apply bg-background text-foreground; } -} \ No newline at end of file +} diff --git a/go.mod b/go.mod index a07fb80..52aa002 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,8 @@ module go-weather go 1.25.6 require github.com/lib/pq v1.11.1 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + golang.org/x/crypto v0.48.0 // indirect +) diff --git a/go.sum b/go.sum index 3ad08b1..889a4f9 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI= github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= diff --git a/handlers/user.go b/handlers/user.go new file mode 100644 index 0000000..615c8c0 --- /dev/null +++ b/handlers/user.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "encoding/json" + "go-weather/models" + "go-weather/services" + "net/http" + "strings" +) + +type UserHandler struct { + userService *services.UserService +} + +func NewUserHandler(us *services.UserService) *UserHandler { + return &UserHandler{userService: us} +} + +func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req models.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + req.Email = strings.TrimSpace(strings.ToLower(req.Email)) + if req.Email == "" || req.Password == "" { + http.Error(w, "email and password are required", http.StatusBadRequest) + return + } + + if len(req.Password) < 8 { + http.Error(w, "password must be at least 8 characters", http.StatusBadRequest) + return + } + + exists, err := h.userService.EmailExists(req.Email) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + if exists { + http.Error(w, "email already registered", http.StatusConflict) + return + } + + user, err := h.userService.CreateUser(req.Email, req.Password) + if err != nil { + http.Error(w, "Failed to create user", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(user) +} diff --git a/main.go b/main.go index 52140e8..ea95ff5 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,6 @@ package main import ( "database/sql" "fmt" - _ "github.com/lib/pq" "go-weather/config" "go-weather/handlers" "go-weather/services" @@ -12,6 +11,8 @@ import ( "os" "os/signal" "time" + + _ "github.com/lib/pq" ) func startPoller(db *sql.DB, ws *services.WeatherService, defaultCity string, interval time.Duration) { @@ -58,13 +59,15 @@ func main() { log.Fatalf("failed to connect to db: %v", err) } defer database.Close() - databaseService := services.NewDatabaseService(database) // Create services + databaseService := services.NewDatabaseService(database) weatherService := services.NewWeatherService(config.APIKey) + userService := services.NewUserService(database) // Create route handlers weatherHandler := handlers.NewWeatherHandler(weatherService, databaseService, config.DefaultCity) + userHandler := handlers.NewUserHandler(userService) // Start background poller go startPoller(database, weatherService, config.DefaultCity, 10*time.Minute) @@ -75,6 +78,7 @@ func main() { http.HandleFunc("/health", handlers.Health) http.HandleFunc("/latest-temperature", handlers.LatestTemperature) http.HandleFunc("/test", handlers.Test) + http.HandleFunc("/register", userHandler.Register) // Start server go func() { fmt.Println("server listening on :8080") diff --git a/migrations/schema.sql b/migrations/schema.sql index b0b80d1..4c66053 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -6,4 +6,12 @@ CREATE TABLE IF NOT EXISTS weather_readings ( humidity INTEGER NOT NULL, conditions TEXT NOT NULL, fetched_at TIMESTAMP WITH TIME ZONE NOT NULL -) +); + + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..3353c3d --- /dev/null +++ b/models/user.go @@ -0,0 +1,27 @@ +package models + +import ( + "time" +) + +type User struct { + ID int `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"created_at"` +} + +type RegisterRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type LoginResponse struct { + User User `json:"user"` + Token string `json:"token"` +} diff --git a/postgres/docker-compose.yaml b/postgres/docker-compose.yaml index 70725d6..03fda9d 100644 --- a/postgres/docker-compose.yaml +++ b/postgres/docker-compose.yaml @@ -11,5 +11,13 @@ services: volumes: - pgdata:/var/lib/postgresql/data + cloudbeaver: + image: dbeaver/cloudbeaver:latest + container_name: cloudbeaver-dev + ports: + - "8978:8978" + depends_on: + - postgres + volumes: pgdata: diff --git a/services/auth.go b/services/auth.go new file mode 100644 index 0000000..6e5289d --- /dev/null +++ b/services/auth.go @@ -0,0 +1,57 @@ +package services + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID int `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +type AuthService struct { + secretKey []byte +} + +func NewAuthService(secret string) *AuthService { + return &AuthService{secretKey: []byte(secret)} +} + +func (s *AuthService) GenerateToken(userID int, email string) (string, error) { + claims := Claims{ + UserID: userID, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + return token.SignedString(s.secretKey) +} + +func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return s.secretKey, nil + + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") + +} diff --git a/services/database.go b/services/database.go index a9523f9..ec024ae 100644 --- a/services/database.go +++ b/services/database.go @@ -5,7 +5,6 @@ import ( "fmt" _ "github.com/lib/pq" "go-weather/models" - "log" "time" ) diff --git a/services/user.go b/services/user.go new file mode 100644 index 0000000..9516154 --- /dev/null +++ b/services/user.go @@ -0,0 +1,66 @@ +package services + +import ( + "database/sql" + "go-weather/models" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type UserService struct { + db *sql.DB +} + +func NewUserService(db *sql.DB) *UserService { + return &UserService{db: db} +} + +func (s *UserService) CreateUser(email string, password string) (*models.User, error) { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + + if err != nil { + return nil, err + } + + var user models.User + err = s.db.QueryRow( + `INSERT INTO users (email, password_hash, created_at) + VALUES ($1, $2, $3) + RETURNING id, email, created_at`, + email, string(hashedPassword), time.Now(), + ).Scan(&user.ID, &user.Email, &user.CreatedAt) + + if err != nil { + return nil, err + } + + return &user, nil + +} + +func (s *UserService) GetUserByEmail(email string) (*models.User, error) { + + var user models.User + err := s.db.QueryRow( + `SELECT id, email, password_hash, created_at + FROM users + WHERE email = $1`, email, + ).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt) + + if err != nil { + return nil, nil + } + + return &user, nil +} + +func (s *UserService) EmailExists(email string) (bool, error) { + var exists bool + err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email).Scan(&exists) + return exists, err +} +func (s *UserService) ValidatePassword(user *models.User, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) + return err == nil +}