JWT proto with login & registration

This commit is contained in:
Mephimeow
2026-06-12 09:12:18 +00:00
committed by zero@thinky
parent ea645860cf
commit 321cba3f9b
14 changed files with 1199 additions and 58 deletions
+47
View File
@@ -0,0 +1,47 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
func GenerateToken(userID, email string, secret []byte, expiration time.Duration) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
func ValidateToken(tokenString string, secret []byte) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
+107
View File
@@ -0,0 +1,107 @@
package auth
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// @Summary Epta registration
// @Description Create user account with username, email, password
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RegisterRequest true "Registration details"
// @Success 201 {object} UserResponse
// @Failure 400 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Router /api/auth/register [post]
func (h *Handler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
user, err := h.service.Register(c.Request.Context(), req)
if err != nil {
if errors.Is(err, ErrEmailExists) {
c.JSON(http.StatusConflict, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
return
}
c.JSON(http.StatusCreated, UserResponse{User: *user})
}
// @Summary Epta login
// @Description Authenticate user with email and password, returns JWT token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body LoginRequest true "Login credentials"
// @Success 200 {object} AuthResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Router /api/auth/login [post]
func (h *Handler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
resp, err := h.service.Login(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
// @Summary Epta get current user
// @Description Get authenticated user's profile
// @Tags auth
// @Accept json
// @Produce json
// @Security Bearer
// @Success 200 {object} UserResponse
// @Failure 401 {object} ErrorResponse
// @Router /api/auth/me [get]
func (h *Handler) Me(c *gin.Context) {
rawUserID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"})
return
}
userID, ok := rawUserID.(string)
if !ok {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "invalid user ID in context"})
return
}
user, err := h.service.GetUserByID(c.Request.Context(), userID)
if err != nil {
if errors.Is(err, ErrUserNotFound) || errors.Is(err, ErrInvalidUserID) {
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
return
}
c.JSON(http.StatusOK, UserResponse{User: *user})
}
+34
View File
@@ -0,0 +1,34 @@
package auth
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func AuthMiddleware(jwtSecret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse{Error: "authorization header required"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse{Error: "invalid authorization header format"})
return
}
claims, err := ValidateToken(parts[1], jwtSecret)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse{Error: "invalid or expired token"})
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Next()
}
}
+55
View File
@@ -0,0 +1,55 @@
package auth
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type User struct {
ID bson.ObjectID `json:"id" bson:"_id"`
Username string `json:"username" bson:"username"`
Email string `json:"email" bson:"email"`
PasswordHash string `json:"-" bson:"password_hash"`
CreatedAt time.Time `json:"created_at" bson:"created_at"`
}
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=30" example:"john"`
Email string `json:"email" binding:"required,email" example:"john@example.com"`
Password string `json:"password" binding:"required,min=6" example:"secret123"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email" example:"john@example.com"`
Password string `json:"password" binding:"required" example:"secret123"`
}
type AuthResponse struct {
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."`
User UserPublic `json:"user"`
}
type UserPublic struct {
ID bson.ObjectID `json:"id" bson:"_id"`
Username string `json:"username" bson:"username"`
Email string `json:"email" bson:"email"`
CreatedAt time.Time `json:"created_at" bson:"created_at"`
}
func NewUserPublic(u *User) UserPublic {
return UserPublic{
ID: u.ID,
Username: u.Username,
Email: u.Email,
CreatedAt: u.CreatedAt,
}
}
type UserResponse struct {
User UserPublic `json:"user"`
}
type ErrorResponse struct {
Error string `json:"error" example:"invalid email or password"`
}
+53
View File
@@ -0,0 +1,53 @@
package auth
import (
"context"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
type Repository struct {
collection *mongo.Collection
}
func NewRepository(db *mongo.Database) *Repository {
return &Repository{
collection: db.Collection("users"),
}
}
func (r *Repository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true),
})
return err
}
func (r *Repository) Create(ctx context.Context, user *User) error {
user.ID = bson.NewObjectID()
user.CreatedAt = time.Now().UTC()
_, err := r.collection.InsertOne(ctx, user)
return err
}
func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, error) {
var user User
err := r.collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
if err != nil {
return nil, err
}
return &user, nil
}
func (r *Repository) FindByID(ctx context.Context, id bson.ObjectID) (*User, error) {
var user User
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user)
if err != nil {
return nil, err
}
return &user, nil
}
+103
View File
@@ -0,0 +1,103 @@
package auth
import (
"context"
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"golang.org/x/crypto/bcrypt"
)
var (
ErrEmailExists = errors.New("email already registered")
ErrInvalidCreds = errors.New("invalid email or password")
ErrUserNotFound = errors.New("user not found")
ErrInvalidUserID = errors.New("invalid user ID")
)
type Service struct {
repo *Repository
jwtSecret []byte
jwtExp time.Duration
}
func NewService(repo *Repository, jwtSecret string, jwtExp time.Duration) *Service {
return &Service{
repo: repo,
jwtSecret: []byte(jwtSecret),
jwtExp: jwtExp,
}
}
func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPublic, error) {
existing, err := s.repo.FindByEmail(ctx, req.Email)
if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
return nil, fmt.Errorf("failed to check existing user: %w", err)
}
if existing != nil {
return nil, ErrEmailExists
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
user := &User{
Username: req.Username,
Email: req.Email,
PasswordHash: string(hash),
}
if err := s.repo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
public := NewUserPublic(user)
return &public, nil
}
func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) {
user, err := s.repo.FindByEmail(ctx, req.Email)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrInvalidCreds
}
return nil, fmt.Errorf("failed to find user: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
return nil, ErrInvalidCreds
}
token, err := GenerateToken(user.ID.Hex(), user.Email, s.jwtSecret, s.jwtExp)
if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
return &AuthResponse{
Token: token,
User: NewUserPublic(user),
}, nil
}
func (s *Service) GetUserByID(ctx context.Context, userID string) (*UserPublic, error) {
id, err := bson.ObjectIDFromHex(userID)
if err != nil {
return nil, ErrInvalidUserID
}
user, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("failed to find user: %w", err)
}
public := NewUserPublic(user)
return &public, nil
}
+50
View File
@@ -0,0 +1,50 @@
package config
import (
"fmt"
"os"
"time"
"github.com/joho/godotenv"
)
type Config struct {
ServerPort string
MongoURI string
MongoDB string
JWTSecret string
JWTExpiration time.Duration
}
func Load() (*Config, error) {
godotenv.Load()
cfg := &Config{
ServerPort: getEnv("SERVER_PORT", "8080"),
MongoURI: getEnv("MONGO_URI", "mongodb://localhost:27017"),
MongoDB: getEnv("MONGO_DB", "aegisguard"),
JWTSecret: getEnv("JWT_SECRET", ""),
JWTExpiration: 24 * time.Hour,
}
if cfg.JWTSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required in .env file")
}
if expStr := os.Getenv("JWT_EXPIRATION"); expStr != "" {
d, err := time.ParseDuration(expStr)
if err != nil {
return nil, fmt.Errorf("invalid JWT_EXPIRATION: %w", err)
}
cfg.JWTExpiration = d
}
return cfg, nil
}
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}