start implementing auth

This commit is contained in:
2026-02-23 14:34:19 -05:00
parent 715c122eb7
commit af9c23c521
14 changed files with 341 additions and 24 deletions
+2
View File
@@ -13,6 +13,7 @@ type Config struct {
DBUser string DBUser string
DBPassword string DBPassword string
DBName string DBName string
JWTSecret string
} }
func GetEnv(key string) string { func GetEnv(key string) string {
@@ -32,5 +33,6 @@ func Load() *Config {
DBUser: GetEnv("DB_USER"), DBUser: GetEnv("DB_USER"),
DBPassword: GetEnv("DB_PASSWORD"), DBPassword: GetEnv("DB_PASSWORD"),
DBName: GetEnv("DB_NAME"), DBName: GetEnv("DB_NAME"),
JWTSecret: GetEnv("JWT_SECRET"),
} }
} }
+4
View File
@@ -3,6 +3,7 @@ import { ThemeProvider } from "@/components/theme-provider"
import LatestTemperature from "@/components/latest-temperature" import LatestTemperature from "@/components/latest-temperature"
import TemperatureHistory from "@/components/temperature-history" import TemperatureHistory from "@/components/temperature-history"
import TemperaturePlot from "@/components/temperature-plot" import TemperaturePlot from "@/components/temperature-plot"
import RegisterForm from './components/register-form'
// import { Button } from "@/components/ui/button" // import { Button } from "@/components/ui/button"
@@ -12,6 +13,9 @@ function App() {
return ( return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<main className="min-h-screen bg-background p-6 w-full"> <main className="min-h-screen bg-background p-6 w-full">
<div>
<RegisterForm />
</div>
<div className="w-full mx-auto space-y-2"> <div className="w-full mx-auto space-y-2">
<TemperaturePlot /> <TemperaturePlot />
<div className="grid grid-cols-4 gap-6"> <div className="grid grid-cols-4 gap-6">
+89
View File
@@ -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<string | null>(null)
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
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 (
<form onSubmit={handleSubmit} className="p-4 m-6 flex flex-col border rounded-lg bg-card">
<h2>Register</h2>
{error && <p>{error}</p>}
{success && <p>Account created</p>}
<input
type="email"
placeholder="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="p-2 border rounded bg-background"
/>
<input
type="password"
placeholder="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="p-2 border rounded bg-background"
/>
<input
type="password"
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="p-2 border rounded bg-background"
/>
<Button type="submit" className="w-full">
{loading ? "Creating ..." : "Register"}
</Button>
</form>
)
}
-18
View File
@@ -72,24 +72,6 @@ h1 {
line-height: 1.1; 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) { @media (prefers-color-scheme: light) {
:root { :root {
+5
View File
@@ -3,3 +3,8 @@ module go-weather
go 1.25.6 go 1.25.6
require github.com/lib/pq v1.11.1 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
)
+4
View File
@@ -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 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= 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=
+62
View File
@@ -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)
}
+6 -2
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
_ "github.com/lib/pq"
"go-weather/config" "go-weather/config"
"go-weather/handlers" "go-weather/handlers"
"go-weather/services" "go-weather/services"
@@ -12,6 +11,8 @@ import (
"os" "os"
"os/signal" "os/signal"
"time" "time"
_ "github.com/lib/pq"
) )
func startPoller(db *sql.DB, ws *services.WeatherService, defaultCity string, interval time.Duration) { 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) log.Fatalf("failed to connect to db: %v", err)
} }
defer database.Close() defer database.Close()
databaseService := services.NewDatabaseService(database)
// Create services // Create services
databaseService := services.NewDatabaseService(database)
weatherService := services.NewWeatherService(config.APIKey) weatherService := services.NewWeatherService(config.APIKey)
userService := services.NewUserService(database)
// Create route handlers // Create route handlers
weatherHandler := handlers.NewWeatherHandler(weatherService, databaseService, config.DefaultCity) weatherHandler := handlers.NewWeatherHandler(weatherService, databaseService, config.DefaultCity)
userHandler := handlers.NewUserHandler(userService)
// Start background poller // Start background poller
go startPoller(database, weatherService, config.DefaultCity, 10*time.Minute) go startPoller(database, weatherService, config.DefaultCity, 10*time.Minute)
@@ -75,6 +78,7 @@ func main() {
http.HandleFunc("/health", handlers.Health) http.HandleFunc("/health", handlers.Health)
http.HandleFunc("/latest-temperature", handlers.LatestTemperature) http.HandleFunc("/latest-temperature", handlers.LatestTemperature)
http.HandleFunc("/test", handlers.Test) http.HandleFunc("/test", handlers.Test)
http.HandleFunc("/register", userHandler.Register)
// Start server // Start server
go func() { go func() {
fmt.Println("server listening on :8080") fmt.Println("server listening on :8080")
+9 -1
View File
@@ -6,4 +6,12 @@ CREATE TABLE IF NOT EXISTS weather_readings (
humidity INTEGER NOT NULL, humidity INTEGER NOT NULL,
conditions TEXT NOT NULL, conditions TEXT NOT NULL,
fetched_at TIMESTAMP WITH TIME ZONE 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()
);
+27
View File
@@ -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"`
}
+8
View File
@@ -11,5 +11,13 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
cloudbeaver:
image: dbeaver/cloudbeaver:latest
container_name: cloudbeaver-dev
ports:
- "8978:8978"
depends_on:
- postgres
volumes: volumes:
pgdata: pgdata:
+57
View File
@@ -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")
}
-1
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"go-weather/models" "go-weather/models"
"log"
"time" "time"
) )
+66
View File
@@ -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
}