package public import ( "log" "time" "git.ma-al.com/goc_marek/timetracker/app/config" "git.ma-al.com/goc_marek/timetracker/app/delivery/middleware" "git.ma-al.com/goc_marek/timetracker/app/model" "git.ma-al.com/goc_marek/timetracker/app/service/authService" "git.ma-al.com/goc_marek/timetracker/app/utils/i18n" "git.ma-al.com/goc_marek/timetracker/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) // 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) } // 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 := response.User.Lang if lang == "" { lang = "en" } return c.Redirect().To(h.config.App.BaseURL + "/" + lang) }