package auth import ( "database/sql" "encoding/json" "net/http" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" "github.com/veylant/ia-gateway/internal/apierror" "github.com/veylant/ia-gateway/internal/middleware" ) // LoginHandler handles POST /v1/auth/login (public — no JWT required). type LoginHandler struct { db *sql.DB jwtSecret string jwtTTLHours int logger *zap.Logger } // NewLoginHandler creates a LoginHandler. func NewLoginHandler(db *sql.DB, jwtSecret string, jwtTTLHours int, logger *zap.Logger) *LoginHandler { return &LoginHandler{db: db, jwtSecret: jwtSecret, jwtTTLHours: jwtTTLHours, logger: logger} } type loginRequest struct { Email string `json:"email"` Password string `json:"password"` } type loginResponse struct { Token string `json:"token"` User userInfo `json:"user"` } type userInfo struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Role string `json:"role"` TenantID string `json:"tenant_id"` Department string `json:"department,omitempty"` } // ServeHTTP handles the login request. func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if h.db == nil { apierror.WriteError(w, &apierror.APIError{ Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented, }) return } var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON")) return } if req.Email == "" || req.Password == "" { apierror.WriteError(w, apierror.NewBadRequestError("email and password are required")) return } // Look up user by email (cross-tenant — each email is unique per tenant, but // for V1 single-tenant we look globally). var u userInfo var passwordHash string err := h.db.QueryRowContext(r.Context(), `SELECT id, tenant_id, email, COALESCE(name,''), role, COALESCE(department,''), password_hash FROM users WHERE email = $1 AND is_active = TRUE LIMIT 1`, req.Email, ).Scan(&u.ID, &u.TenantID, &u.Email, &u.Name, &u.Role, &u.Department, &passwordHash) if err == sql.ErrNoRows { // Use same error to avoid user enumeration apierror.WriteError(w, apierror.NewAuthError("invalid credentials")) return } if err != nil { h.logger.Error("login db query failed", zap.Error(err)) apierror.WriteError(w, apierror.NewUpstreamError("login failed")) return } if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil { apierror.WriteError(w, apierror.NewAuthError("invalid credentials")) return } claims := &middleware.UserClaims{ UserID: u.ID, TenantID: u.TenantID, Email: u.Email, Roles: []string{u.Role}, Department: u.Department, } token, err := GenerateToken(claims, u.Name, h.jwtSecret, h.jwtTTLHours) if err != nil { h.logger.Error("token generation failed", zap.Error(err)) apierror.WriteError(w, apierror.NewUpstreamError("failed to generate token")) return } h.logger.Info("user logged in", zap.String("email", u.Email), zap.String("tenant_id", u.TenantID)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(loginResponse{Token: token, User: u}) }