mirror of
https://github.com/arkorty/Reduce.git
synced 2026-03-17 16:41:42 +00:00
feat: version 2.1
This commit is contained in:
196
backend/auth.go
196
backend/auth.go
@@ -56,11 +56,21 @@ func parseToken(tokenStr string) (*JWTClaims, error) {
|
||||
// JWTMiddleware requires a valid JWT token
|
||||
func JWTMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
auth := c.Request().Header.Get("Authorization")
|
||||
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing or invalid token")
|
||||
// Try to get token from cookie first
|
||||
cookie, err := c.Cookie("token")
|
||||
var tokenStr string
|
||||
if err == nil && cookie != nil {
|
||||
tokenStr = cookie.Value
|
||||
} else {
|
||||
// Fallback to Authorization header for backward compatibility
|
||||
auth := c.Request().Header.Get("Authorization")
|
||||
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing or invalid token")
|
||||
}
|
||||
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
claims, err := parseToken(strings.TrimPrefix(auth, "Bearer "))
|
||||
|
||||
claims, err := parseToken(tokenStr)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired token")
|
||||
}
|
||||
@@ -73,9 +83,21 @@ func JWTMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
// OptionalJWTMiddleware extracts user if token present, doesn't require it
|
||||
func OptionalJWTMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
auth := c.Request().Header.Get("Authorization")
|
||||
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
|
||||
if claims, err := parseToken(strings.TrimPrefix(auth, "Bearer ")); err == nil {
|
||||
// Try to get token from cookie first
|
||||
cookie, err := c.Cookie("token")
|
||||
var tokenStr string
|
||||
if err == nil && cookie != nil {
|
||||
tokenStr = cookie.Value
|
||||
} else {
|
||||
// Fallback to Authorization header
|
||||
auth := c.Request().Header.Get("Authorization")
|
||||
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
|
||||
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
}
|
||||
|
||||
if tokenStr != "" {
|
||||
if claims, err := parseToken(tokenStr); err == nil {
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
}
|
||||
@@ -122,9 +144,20 @@ func register(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token")
|
||||
}
|
||||
|
||||
// Set httpOnly cookie
|
||||
cookie := &http.Cookie{
|
||||
Name: "token",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: os.Getenv("ENV") == "production", // Only secure in production
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 72 * 3600, // 72 hours
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||
"token": token,
|
||||
"user": map[string]interface{}{"id": user.ID, "username": user.Username},
|
||||
"user": map[string]interface{}{"id": user.ID, "username": user.Username},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -152,9 +185,36 @@ func login(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token")
|
||||
}
|
||||
|
||||
// Set httpOnly cookie
|
||||
cookie := &http.Cookie{
|
||||
Name: "token",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: os.Getenv("ENV") == "production", // Only secure in production
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 72 * 3600, // 72 hours
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"token": token,
|
||||
"user": map[string]interface{}{"id": user.ID, "username": user.Username},
|
||||
"user": map[string]interface{}{"id": user.ID, "username": user.Username},
|
||||
})
|
||||
}
|
||||
|
||||
func logout(c echo.Context) error {
|
||||
// Clear the cookie
|
||||
cookie := &http.Cookie{
|
||||
Name: "token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1, // Delete cookie
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "Logged out successfully",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,3 +229,117 @@ func getMe(c echo.Context) error {
|
||||
"username": user.Username,
|
||||
})
|
||||
}
|
||||
|
||||
func updateUsername(c echo.Context) error {
|
||||
uid := c.Get("user_id").(uint)
|
||||
type Req struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
r := new(Req)
|
||||
if err := c.Bind(r); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request")
|
||||
}
|
||||
|
||||
r.Username = strings.TrimSpace(r.Username)
|
||||
if len(r.Username) < 3 || len(r.Username) > 32 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Username must be 3–32 characters")
|
||||
}
|
||||
|
||||
// Check if username is already taken by another user
|
||||
var existing User
|
||||
if db.Where("username = ? AND id != ?", r.Username, uid).First(&existing).Error == nil {
|
||||
return echo.NewHTTPError(http.StatusConflict, "Username already taken")
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := db.First(&user, uid).Error; err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "User not found")
|
||||
}
|
||||
|
||||
user.Username = r.Username
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update username")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
})
|
||||
}
|
||||
|
||||
func updatePassword(c echo.Context) error {
|
||||
uid := c.Get("user_id").(uint)
|
||||
type Req struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
r := new(Req)
|
||||
if err := c.Bind(r); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request")
|
||||
}
|
||||
|
||||
if len(r.NewPassword) < 6 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Password must be at least 6 characters")
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := db.First(&user, uid).Error; err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "User not found")
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.CurrentPassword)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Current password is incorrect")
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(r.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to process password")
|
||||
}
|
||||
|
||||
user.Password = string(hash)
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update password")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "Password updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func deleteAccount(c echo.Context) error {
|
||||
uid := c.Get("user_id").(uint)
|
||||
|
||||
var user User
|
||||
if err := db.First(&user, uid).Error; err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "User not found")
|
||||
}
|
||||
|
||||
// Delete all links owned by this user
|
||||
db.Where("user_id = ?", uid).Delete(&Link{})
|
||||
|
||||
// Delete user
|
||||
if err := db.Delete(&user).Error; err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete account")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "Account deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func getUserStats(c echo.Context) error {
|
||||
uid := c.Get("user_id").(uint)
|
||||
|
||||
var linkCount int
|
||||
var totalClicks int
|
||||
|
||||
db.Model(&Link{}).Where("user_id = ?", uid).Count(&linkCount)
|
||||
db.Model(&Link{}).Where("user_id = ?", uid).Select("COALESCE(SUM(click_count), 0)").Row().Scan(&totalClicks)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"link_count": linkCount,
|
||||
"total_clicks": totalClicks,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,6 +148,21 @@ func fetchLURL(c echo.Context) error {
|
||||
}
|
||||
|
||||
if link.RequiresAuth {
|
||||
// Check if user is authenticated and authorized
|
||||
if username, ok := c.Get("username").(string); ok {
|
||||
// User is logged in, check if they match the access credentials
|
||||
if username == link.AccessUsername {
|
||||
// Auto-authorize logged-in user
|
||||
db.Model(&link).UpdateColumn("click_count", gorm.Expr("click_count + 1"))
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"lurl": link.LongURL,
|
||||
"requires_auth": false,
|
||||
"auto_authorized": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// User not logged in or doesn't match - require manual auth
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"requires_auth": true,
|
||||
"code": link.Code,
|
||||
|
||||
@@ -15,13 +15,20 @@ func main() {
|
||||
|
||||
e := echo.New()
|
||||
|
||||
// Frontend URL for CORS
|
||||
frontendURL := os.Getenv("BASE_URL")
|
||||
if frontendURL == "" {
|
||||
frontendURL = "http://localhost:5173"
|
||||
}
|
||||
|
||||
// Middleware
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
|
||||
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
|
||||
AllowOrigins: []string{frontendURL},
|
||||
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
|
||||
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
// Health
|
||||
@@ -32,12 +39,20 @@ func main() {
|
||||
// Auth
|
||||
e.POST("/auth/register", register)
|
||||
e.POST("/auth/login", login)
|
||||
e.POST("/auth/logout", logout)
|
||||
e.GET("/auth/me", getMe, JWTMiddleware)
|
||||
|
||||
// User account management
|
||||
user := e.Group("/user", JWTMiddleware)
|
||||
user.GET("/stats", getUserStats)
|
||||
user.PUT("/username", updateUsername)
|
||||
user.PUT("/password", updatePassword)
|
||||
user.DELETE("/account", deleteAccount)
|
||||
|
||||
// Public link routes (optional auth for shorten)
|
||||
e.POST("/reduce/shorten", shortenURL, OptionalJWTMiddleware)
|
||||
e.GET("/reduce/:code", fetchLURL)
|
||||
e.POST("/reduce/:code/verify", verifyAndRedirect)
|
||||
e.POST("/shorten", shortenURL, OptionalJWTMiddleware)
|
||||
e.GET("/:code", fetchLURL, OptionalJWTMiddleware)
|
||||
e.POST("/:code/verify", verifyAndRedirect)
|
||||
|
||||
// Authenticated link management
|
||||
links := e.Group("/links", JWTMiddleware)
|
||||
|
||||
Reference in New Issue
Block a user