initial commit. Cloned timetracker repository

This commit is contained in:
Daniel Goc
2026-03-10 09:02:57 +01:00
commit f2952bcef0
189 changed files with 21334 additions and 0 deletions

View File

@@ -0,0 +1,416 @@
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/view"
"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": view.GetErrorCode(c, view.ErrInvalidBody),
})
}
// Validate required fields
if req.Email == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrEmailPasswordRequired),
})
}
// Attempt login
response, rawRefreshToken, err := h.authService.Login(&req)
if err != nil {
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.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": view.GetErrorCode(c, view.ErrInvalidBody),
})
}
// Validate email
if req.Email == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.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": view.GetErrorCode(c, view.ErrInvalidBody),
})
}
// Validate required fields
if req.Token == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.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(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.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": view.GetErrorCode(c, view.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(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.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": view.GetErrorCode(c, view.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": view.GetErrorCode(c, view.ErrInvalidBody),
})
}
// Validate required fields
if req.FirstName == "" || req.LastName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrFirstLastNameRequired),
})
}
// Validate required fields
if req.Email == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrEmailPasswordRequired),
})
}
// Attempt registration
err := h.authService.Register(&req)
if err != nil {
log.Printf("Register error: %v", err)
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.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": view.GetErrorCode(c, view.ErrInvalidBody),
})
}
// Validate required fields
if req.Token == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": view.GetErrorCode(c, view.ErrTokenRequired),
})
}
// Attempt to complete registration
response, rawRefreshToken, err := h.authService.CompleteRegistration(&req)
if err != nil {
return c.Status(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.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(view.GetErrorStatus(err)).JSON(fiber.Map{
"error": view.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 + "/chart")
}