426 lines
12 KiB
Go
426 lines
12 KiB
Go
package public
|
|
|
|
import (
|
|
"log"
|
|
"time"
|
|
|
|
"git.ma-al.com/goc_daniel/b2b/app/config"
|
|
"git.ma-al.com/goc_daniel/b2b/app/delivery/middleware"
|
|
"git.ma-al.com/goc_daniel/b2b/app/model"
|
|
"git.ma-al.com/goc_daniel/b2b/app/service/authService"
|
|
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
|
|
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
)
|
|
|
|
// AuthHandler handles authentication endpoints
|
|
type AuthHandler struct {
|
|
authService *authService.AuthService
|
|
config *config.Config
|
|
}
|
|
|
|
// NewAuthHandler creates a new AuthHandler instance
|
|
func NewAuthHandler() *AuthHandler {
|
|
authService := authService.NewAuthService()
|
|
return &AuthHandler{
|
|
authService: authService,
|
|
config: config.Get(),
|
|
}
|
|
}
|
|
|
|
// AuthHandlerRoutes registers all auth routes
|
|
func AuthHandlerRoutes(r fiber.Router) fiber.Router {
|
|
handler := NewAuthHandler()
|
|
|
|
r.Post("/login", handler.Login)
|
|
r.Post("/register", handler.Register)
|
|
r.Post("/complete-registration", handler.CompleteRegistration)
|
|
r.Post("/forgot-password", handler.ForgotPassword)
|
|
r.Post("/reset-password", handler.ResetPassword)
|
|
r.Post("/logout", handler.Logout)
|
|
r.Post("/refresh", handler.RefreshToken)
|
|
r.Post("/update-choice", handler.UpdateJWTToken)
|
|
|
|
// Google OAuth2
|
|
r.Get("/google", handler.GoogleLogin)
|
|
r.Get("/google/callback", handler.GoogleCallback)
|
|
|
|
authProtected := r.Group("", middleware.AuthMiddleware())
|
|
authProtected.Get("/me", handler.Me)
|
|
|
|
return r
|
|
}
|
|
|
|
func (h *AuthHandler) Login(c fiber.Ctx) error {
|
|
var req model.LoginRequest
|
|
|
|
if err := c.Bind().Body(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
|
|
})
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.Email == "" || req.Password == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrEmailPasswordRequired),
|
|
})
|
|
}
|
|
|
|
// Attempt login
|
|
response, rawRefreshToken, err := h.authService.Login(&req)
|
|
if err != nil {
|
|
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, err),
|
|
})
|
|
}
|
|
|
|
// Set cookies for web-based authentication
|
|
h.setAuthCookies(c, response.AccessToken, rawRefreshToken)
|
|
|
|
return c.JSON(response)
|
|
}
|
|
|
|
// setAuthCookies sets the access token (HTTPOnly) and refresh token (HTTPOnly) cookies,
|
|
// plus a non-HTTPOnly is_authenticated flag cookie for frontend state detection.
|
|
func (h *AuthHandler) setAuthCookies(c fiber.Ctx, accessToken, rawRefreshToken string) {
|
|
isProduction := h.config.App.Environment == "production"
|
|
|
|
// HTTPOnly access token cookie — not readable by JS, protects against XSS
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: "access_token",
|
|
Value: accessToken,
|
|
Expires: time.Now().Add(time.Duration(h.config.Auth.JWTExpiration) * time.Second),
|
|
HTTPOnly: true,
|
|
Secure: isProduction,
|
|
SameSite: "Lax",
|
|
})
|
|
|
|
// HTTPOnly refresh token cookie — opaque, stored as hash in DB
|
|
if rawRefreshToken != "" {
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: "refresh_token",
|
|
Value: rawRefreshToken,
|
|
Expires: time.Now().Add(time.Duration(h.config.Auth.RefreshExpiration) * time.Second),
|
|
HTTPOnly: true,
|
|
Secure: isProduction,
|
|
SameSite: "Lax",
|
|
})
|
|
}
|
|
|
|
// Non-HTTPOnly flag cookie — readable by JS to detect auth state.
|
|
// Contains no sensitive data; actual auth is enforced by the HTTPOnly access_token cookie.
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: "is_authenticated",
|
|
Value: "1",
|
|
Expires: time.Now().Add(time.Duration(h.config.Auth.JWTExpiration) * time.Second),
|
|
HTTPOnly: false,
|
|
Secure: isProduction,
|
|
SameSite: "Lax",
|
|
})
|
|
}
|
|
|
|
// clearAuthCookies expires all auth-related cookies
|
|
func (h *AuthHandler) clearAuthCookies(c fiber.Ctx) {
|
|
isProduction := h.config.App.Environment == "production"
|
|
past := time.Now().Add(-time.Hour)
|
|
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: "access_token",
|
|
Value: "",
|
|
Expires: past,
|
|
HTTPOnly: true,
|
|
Secure: isProduction,
|
|
SameSite: "Lax",
|
|
})
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: "refresh_token",
|
|
Value: "",
|
|
Expires: past,
|
|
HTTPOnly: true,
|
|
Secure: isProduction,
|
|
SameSite: "Lax",
|
|
})
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: "is_authenticated",
|
|
Value: "",
|
|
Expires: past,
|
|
HTTPOnly: false,
|
|
Secure: isProduction,
|
|
SameSite: "Lax",
|
|
})
|
|
}
|
|
|
|
// ForgotPassword handles password reset request
|
|
func (h *AuthHandler) ForgotPassword(c fiber.Ctx) error {
|
|
var req struct {
|
|
Email string `json:"email" form:"email"`
|
|
}
|
|
|
|
if err := c.Bind().Body(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
|
|
})
|
|
}
|
|
|
|
// Validate email
|
|
if req.Email == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrEmailRequired),
|
|
})
|
|
}
|
|
|
|
// Request password reset - always return success to prevent email enumeration
|
|
err := h.authService.RequestPasswordReset(req.Email)
|
|
if err != nil {
|
|
log.Printf("Password reset request error: %v", err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"message": i18n.T_(c, "auth.auth_if_account_exists"),
|
|
})
|
|
}
|
|
|
|
// ResetPassword handles password reset completion
|
|
func (h *AuthHandler) ResetPassword(c fiber.Ctx) error {
|
|
var req model.ResetPasswordRequest
|
|
|
|
if err := c.Bind().Body(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
|
|
})
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.Token == "" || req.Password == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrTokenPasswordRequired),
|
|
})
|
|
}
|
|
|
|
// Reset password (also revokes all refresh tokens for the user)
|
|
err := h.authService.ResetPassword(req.Token, req.Password)
|
|
if err != nil {
|
|
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, err),
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"message": i18n.T_(c, "auth.auth_password_reset_successfully"),
|
|
})
|
|
}
|
|
|
|
// Logout handles user logout — revokes the refresh token from DB and clears all cookies
|
|
func (h *AuthHandler) Logout(c fiber.Ctx) error {
|
|
// Revoke the refresh token from the database
|
|
rawRefreshToken := c.Cookies("refresh_token")
|
|
if rawRefreshToken != "" {
|
|
h.authService.RevokeRefreshToken(rawRefreshToken)
|
|
}
|
|
|
|
// Clear all auth cookies
|
|
h.clearAuthCookies(c)
|
|
|
|
return c.JSON(fiber.Map{
|
|
"message": i18n.T_(c, "auth.auth_logged_out_successfully"),
|
|
})
|
|
}
|
|
|
|
// RefreshToken handles token refresh — validates opaque refresh token, rotates it, issues new access token
|
|
func (h *AuthHandler) RefreshToken(c fiber.Ctx) error {
|
|
// Get refresh token from HTTPOnly cookie (preferred) or request body (fallback for API clients)
|
|
rawRefreshToken := c.Cookies("refresh_token")
|
|
if rawRefreshToken == "" {
|
|
var body struct {
|
|
RefreshToken string `json:"refresh_token"`
|
|
}
|
|
if err := c.Bind().Body(&body); err == nil {
|
|
rawRefreshToken = body.RefreshToken
|
|
}
|
|
}
|
|
|
|
if rawRefreshToken == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrRefreshTokenRequired),
|
|
})
|
|
}
|
|
|
|
response, newRawRefreshToken, err := h.authService.RefreshToken(rawRefreshToken)
|
|
if err != nil {
|
|
// If refresh token is invalid/expired, clear cookies
|
|
h.clearAuthCookies(c)
|
|
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, err),
|
|
})
|
|
}
|
|
|
|
// Set new cookies (rotated refresh token + new access token)
|
|
h.setAuthCookies(c, response.AccessToken, newRawRefreshToken)
|
|
|
|
return c.JSON(response)
|
|
}
|
|
|
|
// Me returns the current user info
|
|
func (h *AuthHandler) Me(c fiber.Ctx) error {
|
|
user := c.Locals("user")
|
|
if user == nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrNotAuthenticated),
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"user": user,
|
|
})
|
|
}
|
|
|
|
// Register handles user registration
|
|
func (h *AuthHandler) Register(c fiber.Ctx) error {
|
|
var req model.RegisterRequest
|
|
|
|
if err := c.Bind().Body(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
|
|
})
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.FirstName == "" || req.LastName == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrFirstLastNameRequired),
|
|
})
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.Email == "" || req.Password == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrEmailPasswordRequired),
|
|
})
|
|
}
|
|
|
|
// Attempt registration
|
|
err := h.authService.Register(&req)
|
|
if err != nil {
|
|
log.Printf("Register error: %v", err)
|
|
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, err),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
|
"message": i18n.T_(c, "auth.auth_registration_successful"),
|
|
})
|
|
}
|
|
|
|
// CompleteRegistration handles completion of registration with password
|
|
func (h *AuthHandler) CompleteRegistration(c fiber.Ctx) error {
|
|
var req model.CompleteRegistrationRequest
|
|
|
|
if err := c.Bind().Body(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrInvalidBody),
|
|
})
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.Token == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrTokenRequired),
|
|
})
|
|
}
|
|
|
|
// Attempt to complete registration
|
|
response, rawRefreshToken, err := h.authService.CompleteRegistration(&req)
|
|
if err != nil {
|
|
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, err),
|
|
})
|
|
}
|
|
|
|
// Set cookies for web-based authentication
|
|
h.setAuthCookies(c, response.AccessToken, rawRefreshToken)
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(response)
|
|
}
|
|
|
|
// CompleteRegistration handles completion of registration with password
|
|
func (h *AuthHandler) UpdateJWTToken(c fiber.Ctx) error {
|
|
return h.UpdateJWTToken(c)
|
|
}
|
|
|
|
// GoogleLogin redirects the user to Google's OAuth2 consent page
|
|
func (h *AuthHandler) GoogleLogin(c fiber.Ctx) error {
|
|
// Generate a random state token and store it in a short-lived cookie
|
|
state, err := h.authService.GenerateOAuthState()
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"error": i18n.T_(c, "error.err_internal_server_error"),
|
|
})
|
|
}
|
|
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: "oauth_state",
|
|
Value: state,
|
|
Expires: time.Now().Add(10 * time.Minute),
|
|
HTTPOnly: true,
|
|
Secure: h.config.App.Environment == "production",
|
|
SameSite: "Lax",
|
|
})
|
|
|
|
url := h.authService.GetGoogleAuthURL(state)
|
|
return c.Redirect().To(url)
|
|
}
|
|
|
|
// GoogleCallback handles the OAuth2 callback from Google
|
|
func (h *AuthHandler) GoogleCallback(c fiber.Ctx) error {
|
|
// Validate state to prevent CSRF
|
|
cookieState := c.Cookies("oauth_state")
|
|
queryState := c.Query("state")
|
|
if cookieState == "" || cookieState != queryState {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": i18n.T_(c, "error.err_invalid_token"),
|
|
})
|
|
}
|
|
|
|
// Clear the state cookie
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: "oauth_state",
|
|
Value: "",
|
|
Expires: time.Now().Add(-time.Hour),
|
|
HTTPOnly: true,
|
|
Secure: h.config.App.Environment == "production",
|
|
SameSite: "Lax",
|
|
})
|
|
|
|
code := c.Query("code")
|
|
if code == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": i18n.T_(c, "error.err_invalid_body"),
|
|
})
|
|
}
|
|
|
|
response, rawRefreshToken, err := h.authService.HandleGoogleCallback(code)
|
|
if err != nil {
|
|
log.Printf("Google OAuth callback error: %v", err)
|
|
return c.Status(responseErrors.GetErrorStatus(err)).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, err),
|
|
})
|
|
}
|
|
|
|
// Set cookies for web-based authentication (including is_authenticated flag)
|
|
h.setAuthCookies(c, response.AccessToken, rawRefreshToken)
|
|
|
|
// Redirect to the locale-prefixed charts page after successful Google login.
|
|
// The user's preferred language is stored in the auth response; fall back to "en".
|
|
lang, err := h.authService.GetLangISOCode(response.User.LangID)
|
|
if err != nil {
|
|
return c.Status(responseErrors.GetErrorStatus(responseErrors.ErrBadLangID)).JSON(fiber.Map{
|
|
"error": responseErrors.GetErrorCode(c, responseErrors.ErrBadLangID),
|
|
})
|
|
}
|
|
|
|
return c.Redirect().To(h.config.App.BaseURL + "/" + lang)
|
|
}
|