veylant/internal/auth/login.go
2026-03-10 09:20:38 +01:00

117 lines
3.3 KiB
Go

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})
}