Backend structure / login with JWT
This commit is contained in:
2
backend/.env-example
Normal file
2
backend/.env-example
Normal file
@@ -0,0 +1,2 @@
|
||||
# This is secret key for jwt signature
|
||||
JWT_SECRET=just a random string here
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
tmp
|
||||
.env
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
135
backend/controllers/user/user.go
Normal file
135
backend/controllers/user/user.go
Normal 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"})
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
49
backend/models/user/user.go
Normal file
49
backend/models/user/user.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package utils
|
||||
package hash
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
30
backend/utils/jwt/jwt.go
Normal file
30
backend/utils/jwt/jwt.go
Normal 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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package utils
|
||||
package responses
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
32
frontend/src/lib/tryCatch.ts
Normal file
32
frontend/src/lib/tryCatch.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user