Backend structure / login with JWT

This commit is contained in:
Leons Aleksandrovs
2025-07-06 16:46:21 +03:00
parent 3166424426
commit 3003a961b6
18 changed files with 338 additions and 126 deletions

2
backend/.env-example Normal file
View File

@@ -0,0 +1,2 @@
# This is secret key for jwt signature
JWT_SECRET=just a random string here

1
backend/.gitignore vendored
View File

@@ -1 +1,2 @@
tmp
.env

View File

@@ -9,12 +9,14 @@ func defaultValue(val string, def string) string {
return val
}
func LoadEnv() map[string]string {
var Env map[string]string
func LoadEnv() {
// Create object where to store used variables
env := make(map[string]string, 1)
Env = make(map[string]string)
// Get env variables that will be used while server is running
env["db"] = defaultValue(os.Getenv("POSTGRES_DB"), "postgresql://postgres:postgres@db:5432/cover-letter")
return env
Env["db"] = defaultValue(os.Getenv("POSTGRES_DB"), "postgresql://postgres:postgres@db:5432/cover-letter")
Env["JWT_SECRET"] = defaultValue(os.Getenv("JWT_SECRET"), "just a random string here")
Env["Environment"] = defaultValue(os.Getenv("Environment"), "dev")
}

View File

@@ -0,0 +1,135 @@
package user
import (
"backend/config"
"backend/models/user"
"backend/utils/hash"
"backend/utils/jwt"
"errors"
"fmt"
"log"
"net/http"
res "backend/utils/responses"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/jackc/pgx/v5/pgconn"
)
type RegisterForm struct {
Email string `json:"email" validate:"required,email"`
Name string `json:"name" validate:"required,min=2,max=50"`
Password string `json:"password" validate:"required,min=8"`
RepeatPassword string `json:"repeatPassword" validate:"required,min=8,eqfield=Password"`
}
func Register(c *gin.Context) {
// Receive data from frontend, check if data is okay, hash password, call model
var data RegisterForm
if err := c.ShouldBindJSON(&data); err != nil {
res.Error(c, err.Error(), http.StatusInternalServerError)
return
}
// Validate data
validate := validator.New()
if err := validate.Struct(data); err != nil {
// Handle error
res.Error(c, err.Error(), http.StatusBadRequest)
return
}
// Hash password
hash, err := hash.HashPassword(data.Password)
if err != nil {
res.Error(c, err.Error(), http.StatusInternalServerError)
return
}
// Insert into database
if err := user.Create(data.Email, data.Name, hash); err != nil {
// Find out postgres error
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
// Unknown error
res.Error(c, fmt.Sprintf("[UNEXPECTED DB ERROR] %v", err.Error()), http.StatusInternalServerError)
return
}
// Postgres error
log.Printf("[ERROR] Postgres code: %s", pgErr.Code)
if pgErr.Code == "23505" {
// UNIQUE constraint violation (EMAIL TAKEN)
res.Error(c, "Email already exists", http.StatusBadRequest)
return
}
// Unknown error
res.Error(c, fmt.Sprintf("[UNKNOWN ERROR] %v", err.Error()), http.StatusInternalServerError)
return
}
// Return success
res.Success(c, gin.H{
"message": "Successfully registered",
})
}
type LoginForm struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
func Login(c *gin.Context) {
// Bind data
var data LoginForm
if err := c.ShouldBindJSON(&data); err != nil {
res.Error(c, err.Error(), http.StatusBadRequest)
return
}
// Validate data
validate := validator.New()
if err := validate.Struct(data); err != nil {
res.Error(c, err.Error(), http.StatusBadRequest)
return
}
// Find user in database
user, err := user.FindByEmail(data.Email)
if err != nil {
var pgErr *pgconn.PgError
// Check if pg err
if errors.As(err, &pgErr) {
res.Error(c, pgErr.Message, http.StatusInternalServerError)
return
}
// Email not found
res.Error(c, "Email or password are incorrect", http.StatusUnauthorized)
return
}
// Check hash
match := hash.CheckPasswordHash(data.Password, user.Password)
if !match {
res.Error(c, "Email or password are incorrect", http.StatusUnauthorized)
return
}
// Generate JWT token, and send to client
signedToken, err := jwt.GenerateJWT(user)
if err != nil {
res.Error(c, fmt.Sprintf("[JWT Generation] %s", err.Error()), http.StatusInternalServerError)
return
}
// Return token as cookie
secureCookie := config.Env["Environment"] != "dev" // In dev environment cookie wont be secure
// 3600S -> 1H * 24H -> 1D * 7 -> 1W
c.SetCookie("jwt-token", signedToken, 3600*24*7, "/", "localhost", secureCookie, true)
// Return successful login
res.Success(c, gin.H{"message": "Successfully logged in"})
}

View File

@@ -1,75 +0,0 @@
package controllers
import (
"backend/models"
"backend/utils"
"errors"
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/jackc/pgx/v5/pgconn"
)
type User struct{}
type RegisterForm struct {
Email string `json:"email" validate:"required,email"`
Name string `json:"name" validate:"required,min=2,max=50"`
Password string `json:"password" validate:"required,min=8"`
RepeatPassword string `json:"repeatPassword" validate:"required,min=8,eqfield=Password"`
}
func (u *User) Register(c *gin.Context) {
// Receive data from frontend, check if data is okay, hash password, call model
var data RegisterForm
if err := c.ShouldBindJSON(&data); err != nil {
utils.Error(c, err.Error(), http.StatusInternalServerError)
}
// Validate data
validate := validator.New()
if err := validate.Struct(data); err != nil {
// Handle error
utils.Error(c, err.Error(), http.StatusBadRequest)
return
}
// Hash password
hash, err := utils.HashPassword(data.Password)
if err != nil {
utils.Error(c, err.Error(), http.StatusInternalServerError)
return
}
// Insert into database
userMod := models.User{}
if err := userMod.Create(data.Email, data.Name, hash); err != nil {
// Find out postgres error
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
// Unknown error
utils.Error(c, fmt.Sprintf("[UNEXPECTED DB ERROR] %v", err.Error()), http.StatusInternalServerError)
return
}
// Postgres error
log.Printf("[ERROR] Postgres code: %s", pgErr.Code)
if pgErr.Code == "23505" {
// UNIQUE constraint violation (EMAIL TAKEN)
utils.Error(c, "Email already exists", http.StatusBadRequest)
return
}
// Unknown error
utils.Error(c, fmt.Sprintf("[UNKNOWN ERROR] %v", err.Error()), http.StatusInternalServerError)
return
}
// Return success
utils.Success(c, gin.H{
"message": "Successfully registered",
})
}

View File

@@ -5,6 +5,7 @@ go 1.24.4
require (
github.com/gin-gonic/gin v1.10.1
github.com/go-playground/validator/v10 v10.20.0
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/jackc/pgx/v5 v5.7.5
golang.org/x/crypto v0.37.0
)

View File

@@ -26,6 +26,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
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/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=

View File

@@ -10,10 +10,10 @@ import (
func main() {
// Load env variables
env := config.LoadEnv()
config.LoadEnv()
// Connect to database
err := db.Connect(env["db"])
err := db.Connect(config.Env["db"])
if err != nil {
os.Exit(1)
}

View File

@@ -0,0 +1,49 @@
package user
import (
"backend/db"
"context"
"strings"
"time"
)
type User struct {
Id int
Email string
Name string
Password string
CreatedAt time.Time
}
func Create(email string, name string, hash string) error {
// Generate background context for managing timeouts and disconnections
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Build database query
query := `INSERT INTO users (email, name, password) VALUES ($1, $2, $3)`
_, err := db.Pool.Exec(ctx, query, strings.ToLower(email), name, hash)
// Return error if any
return err
}
// Symbol * will follow the pointer to its value
func FindByEmail(email string) (*User, error) {
// bg context
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Build query
query := `SELECT * FROM users WHERE email = $1`
row := db.Pool.QueryRow(ctx, query, strings.ToLower(email))
// Scan will map rows to User struct
var u User
if err := row.Scan(&u.Id, &u.Email, &u.Name, &u.Password, &u.CreatedAt); err != nil {
return nil, err
}
// give pointer
return &u, nil
}

View File

@@ -1,28 +0,0 @@
package models
import (
"backend/db"
"context"
"time"
)
type User struct {
ID int
email string
name string
password string
createdAt string
}
func (u *User) Create(email string, name string, hash string) error {
// Generate background context for managing timeouts and disconnections
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Build database query
query := `INSERT INTO users (email, name, password) VALUES ($1, $2, $3)`
_, err := db.Pool.Exec(ctx, query, email, name, hash)
// Return error if any
return err
}

View File

@@ -1,7 +1,7 @@
package routes
import (
"backend/controllers"
"backend/controllers/user"
"github.com/gin-gonic/gin"
)
@@ -9,11 +9,9 @@ import (
func SetupRoutes() *gin.Engine {
r := gin.Default()
// Controllers
users := controllers.User{}
// Guest routes (Register, Login, check auth)
r.POST("/register", users.Register)
r.POST("/register", user.Register)
r.POST("/login", user.Login)
return r
}

View File

@@ -1,4 +1,4 @@
package utils
package hash
import "golang.org/x/crypto/bcrypt"

30
backend/utils/jwt/jwt.go Normal file
View File

@@ -0,0 +1,30 @@
package jwt
import (
"backend/config"
"backend/models/user"
"time"
"github.com/golang-jwt/jwt"
)
func GenerateJWT(u *user.User) (string, error) {
// Generate JWT token
mySigningKey := []byte(config.Env["JWT_SECRET"])
token := jwt.New(jwt.SigningMethodHS256)
// Add claims (Values)
claims := token.Claims.(jwt.MapClaims)
claims["id"] = u.Id
claims["name"] = u.Name
claims["email"] = u.Email
claims["exp"] = time.Now().Add(time.Hour * 24 * 7).Unix() // Expire in 7 days
// Generate signed token
tokenString, err := token.SignedString(mySigningKey)
if err != nil {
return "", err
}
return tokenString, nil
}

View File

@@ -1,4 +1,4 @@
package utils
package responses
import (
"github.com/gin-gonic/gin"

View File

@@ -9,11 +9,15 @@ services:
volumes:
- "./backend:/app"
env_file:
- ./backend/.env
environment:
- GIN_MODE=debug
# - GIN_MODE=release # For production
- POSTGRES_DB=postgresql://postgres:postgres@db:5432/cover-letter
- GIN_MODE=debug
# - POSTGRES_DB=postgresql://username:password@host:port/database_name
- POSTGRES_DB=postgresql://postgres:postgres@db:5432/cover-letter
# - Environment=prod # For production
- Environment=dev
networks:
- cover-letter-network
depends_on:

View File

@@ -2,6 +2,7 @@ import { API_BASE } from "@/consts";
import { normalizeLink } from "./utils";
import type { ApiResponse } from "@/types/api";
import toast from "react-hot-toast";
import { tryCatch } from "./tryCatch";
interface RequestProps<T> {
error?: (err: Error) => void;
@@ -34,7 +35,10 @@ class Requests {
});
// Get response data
const data = (await res.json()) as ApiResponse<T>;
const { data, error } = await tryCatch<ApiResponse<T>>(res.json());
if (error) {
throw new Error(`Parsing error: ${res.statusText} - ${res.status}`);
}
// Check if data is ok
if ("success" in data && !data.success) {

View File

@@ -0,0 +1,32 @@
// Types for the result object with discriminated union
type Success<T> = {
data: T;
error: null;
};
type Failure<E> = {
data: null;
error: E;
};
type Result<T, E = Error> = Success<T> | Failure<E>;
// Main wrapper function
export async function tryCatch<T, E = Error>(promise: Promise<T>): Promise<Result<T, E>> {
try {
const data = await promise;
return { data, error: null };
} catch (error) {
return { data: null, error: error as E };
}
}
// function for sync
export function tryCatchSync<T, E = Error>(callback: () => T): Result<T, E> {
try {
const data = callback();
return { data, error: null };
} catch (error) {
return { data: null, error: error as E };
}
}

View File

@@ -1,14 +1,48 @@
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useAppForm } from "@/hooks/formHook";
import Guest from "@/layouts/Guest";
import { createFileRoute, Link } from "@tanstack/react-router";
import requests from "@/lib/requests";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import toast from "react-hot-toast";
import * as z from "zod/v4";
export const Route = createFileRoute("/login")({
component: RouteComponent,
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().nonempty("Password is required"),
});
function RouteComponent() {
const loading = useState(false);
const navigate = useNavigate();
const form = useAppForm({
defaultValues: {
email: "",
password: "",
},
validators: {
onBlur: loginSchema,
},
onSubmit: ({ value }) => {
requests.post<{ message: string }>("/login", {
data: value,
before() {
// use state to true loading
loading[1](true);
},
success(data) {
navigate({ to: "/" });
},
});
},
});
return (
<Guest className="h-screen w-screen grid place-items-center">
<Card className="w-full max-w-[400px]">
@@ -21,10 +55,31 @@ function RouteComponent() {
</Button>
</CardAction>
</CardHeader>
<CardContent className="space-y-2">
<Input type="email" placeholder="Email address" />
<Input type="password" placeholder="Password" />
<Button className="w-full">Login</Button>
<CardContent className="space-y-3">
<form.AppField
name="email"
children={(f) => (
<f.TextField
label="Email address"
placeholder="Your email address"
type="email"
/>
)}
/>
<form.AppField
name="password"
children={(f) => (
<f.TextField
label="Password"
placeholder="Your accounts password"
type="password"
/>
)}
/>
<Button onClick={form.handleSubmit} className="w-full">
Login
</Button>
</CardContent>
</Card>
</Guest>