JWT proto with login & registration
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user