Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions services/auth/internal/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ package errors
import "errors"

var (
ErrInvalidEmail = errors.New("invalid email")
ErrInvalidInput = errors.New("invalid input")
ErrConflict = errors.New("conflict")
ErrInternal = errors.New("internal error")
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("resource already exists")
ErrTokenGeneration = errors.New("token generation failed ")
ErrInvalidEmail = errors.New("invalid Email")
ErrInvalidInput = errors.New("invalid input")
ErrTokenGeneration = errors.New("token generation failed")
ErrTooManyAttempts = errors.New("too many login attempts, account locked temporarily")
ErrNotFound = errors.New("not found")
)
56 changes: 52 additions & 4 deletions services/auth/internal/service/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"log/slog"
"regexp"
"time"

"golang.org/x/crypto/bcrypt"

Expand All @@ -26,23 +27,48 @@ type authService struct {
store storage.UserStorage
logger *slog.Logger
tokenSvc TokenService
// Add rate limiting map or store here if needed
loginAttempts map[string]int
lockoutTime map[string]time.Time
lockoutDuration time.Duration
maxAttempts int
}

func NewAuthService(store storage.UserStorage, logger *slog.Logger, tokenSvc TokenService) AuthService {
l := logger.With("layer", "service", "component", "authService")
return &authService{store: store, logger: l, tokenSvc: tokenSvc}
return &authService{
store: store,
logger: l,
tokenSvc: tokenSvc,
loginAttempts: make(map[string]int),
lockoutTime: make(map[string]time.Time),
lockoutDuration: 15 * time.Minute,
maxAttempts: 5,
}
}

func (s *authService) Register(ctx context.Context, email, password string) (*model.User, error) {
s.logger.Info("Register called", slog.String("email", email))

if !regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`).MatchString(email) {
if !regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`).MatchString(email) {
s.logger.Error("Invalid email")
return nil, appErr.ErrInvalidEmail
}

if len(password) == 0 {
s.logger.Error("Invalid password")
if len(password) < 8 {
s.logger.Error("Password too short")
return nil, appErr.ErrInvalidInput
}

// Enforce password complexity: at least one uppercase, one lowercase, one digit, one special char
var (
hasUpper = regexp.MustCompile(`[A-Z]`).MatchString
hasLower = regexp.MustCompile(`[a-z]`).MatchString
hasDigit = regexp.MustCompile(`[0-9]`).MatchString
hasSpecial = regexp.MustCompile(`[\W_]`).MatchString
)
if !hasUpper(password) || !hasLower(password) || !hasDigit(password) || !hasSpecial(password) {
s.logger.Error("Password does not meet complexity requirements")
return nil, appErr.ErrInvalidInput
}

Expand All @@ -69,6 +95,18 @@ func (s *authService) Register(ctx context.Context, email, password string) (*mo
func (s *authService) Login(ctx context.Context, email, password string) (*model.User, string, error) {
s.logger.Info("Login called", slog.String("email", email))

// Check if user is locked out
if lockoutUntil, locked := s.lockoutTime[email]; locked {
if time.Now().Before(lockoutUntil) {
s.logger.Warn("User account locked due to too many failed login attempts", slog.String("email", email))
return nil, "", appErr.ErrTooManyAttempts
} else {
// Lockout expired, reset
delete(s.lockoutTime, email)
s.loginAttempts[email] = 0
}
}

user, err := s.store.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
Expand All @@ -82,9 +120,19 @@ func (s *authService) Login(ctx context.Context, email, password string) (*model

// Compare the provided password with the stored hashed password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
s.loginAttempts[email]++
if s.loginAttempts[email] >= s.maxAttempts {
s.lockoutTime[email] = time.Now().Add(s.lockoutDuration)
s.logger.Warn("User account locked due to too many failed login attempts", slog.String("email", email))
return nil, "", appErr.ErrTooManyAttempts
}
s.logger.Warn("Invalid password", slog.String("email", email))
return nil, "", appErr.ErrUnauthorized
}

// Reset login attempts on successful login
s.loginAttempts[email] = 0

token, err := s.tokenSvc.GenerateToken(user)
if err != nil {
s.logger.Error("Token generation failed ", slog.String("email", email))
Expand Down
29 changes: 29 additions & 0 deletions services/auth/internal/service/token_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,19 @@ func (s *jwtService) GenerateToken(user *model.User) (string, error) {
"email": user.Email,
"exp": time.Now().Add(s.expiryTime).Unix(),
"iat": time.Now().Unix(),
"nbf": time.Now().Unix(), // Not valid before now
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.secret))
}

func (s *jwtService) ValidateToken(tokenStr string) (string, string, error) {
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) {
// Validate the signing method to prevent algorithm confuses attack
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
s.logger.Error("Unexpected signing method", slog.String("method", token.Header["alg"].(string)))
return nil, jwt.ErrSignatureInvalid
}
return []byte(s.secret), nil
})

Expand All @@ -63,6 +69,29 @@ func (s *jwtService) ValidateToken(tokenStr string) (string, string, error) {
return "", "", jwt.ErrTokenMalformed
}
email, ok := claims["email"].(string)
if !ok {
s.logger.Error("Invalid email claim", slog.String("email", email))
return "", "", jwt.ErrTokenMalformed
}

// validate time based claims
now := time.Now().Unix()

// check expiry
if exp, ok := claims["exp"].(float64); ok {
if int64(exp) < now {
s.logger.Error("Token expired", slog.Int64("exp", int64(exp)), slog.Int64("now", now))
return "", "", jwt.ErrTokenExpired
}
}

// check not before
if nbf, ok := claims["nbf"].(float64); ok {
if int64(nbf) > now {
s.logger.Error("Token not valid yet", slog.Int64("nbf", int64(nbf)), slog.Int64("now", now))
return "", "", jwt.ErrTokenNotValidYet
}
}

return userID, email, nil
}
3 changes: 0 additions & 3 deletions services/auth/internal/storage/user_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package storage

import (
"context"
"fmt"
"time"

"github.com/jackc/pgx/v5/pgxpool"
Expand Down Expand Up @@ -60,8 +59,6 @@ func (s *userStorage) GetUserByEmail(ctx context.Context, email string) (*model.
if err := row.Scan(&user.ID, &user.Email, &user.Password, &user.CreatedAt); err != nil {
return nil, err
}
fmt.Println(user)

return &user, nil
}

Expand Down
42 changes: 42 additions & 0 deletions services/gateway/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# API Gateway Service Configuration

# Server Configuration
GATEWAY_PORT=8080
GATEWAY_HOST=0.0.0.0
GATEWAY_READ_TIMEOUT=30s
GATEWAY_WRITE_TIMEOUT=30s
GATEWAY_IDLE_TIMEOUT=60s
GATEWAY_SHUTDOWN_TIMEOUT=15s

# Upstream Service URLs
AUTH_SERVICE_URL=http://hcaas_auth:8081
URL_SERVICE_URL=http://hcaas_web:8080
NOTIFICATION_SERVICE_URL=http://hcaas_notification:8082

# Service Timeouts and Retries
AUTH_SERVICE_TIMEOUT=10s
AUTH_SERVICE_RETRIES=3
URL_SERVICE_TIMEOUT=10s
URL_SERVICE_RETRIES=3
NOTIFICATION_SERVICE_TIMEOUT=10s
NOTIFICATION_SERVICE_RETRIES=3

# Security Configuration
JWT_SECRET=your-super-secret-jwt-key-change-in-production-min-32-chars
ALLOWED_ORIGINS=*
TRUSTED_PROXIES=

# Rate Limiting
RATE_LIMIT_ENABLED=true
RATE_LIMIT_RPS=100
RATE_LIMIT_BURST=200

# Observability
METRICS_ENABLED=true
TRACING_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://hcaas_jaeger_all_in_one:4317
OTEL_SERVICE_NAME=hcaas_gateway_service

# Logging
LOG_LEVEL=info
SERVICE_VERSION=v1.0.0
49 changes: 49 additions & 0 deletions services/gateway/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Build stage
FROM golang:1.24-alpine AS builder

# Set working directory
WORKDIR /app

# Install dependencies
RUN apk add --no-cache git ca-certificates tzdata

# Copy go mod files
COPY go.mod go.sum ./

# Download dependencies
RUN go mod download

# Copy source code
COPY . .

# Build the application
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags='-w -s -extldflags "-static"' \
-tags=netgo \
-o gateway \
./cmd/gateway

# Final stage
FROM scratch

# Copy CA certificates for HTTPS requests
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copy timezone data
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo

# Copy the binary
COPY --from=builder /app/gateway /gateway

# Create non-root user
USER 65534:65534

# Expose port
EXPOSE 8080

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/gateway", "healthcheck"] || exit 1

# Run the application
ENTRYPOINT ["/gateway"]
Loading
Loading