851 lines
27 KiB
Go
851 lines
27 KiB
Go
// Package api provides the API endpoints for the gpodder API
|
|
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"pinepods/gpodder-api/internal/db"
|
|
|
|
"github.com/alexedwards/argon2id"
|
|
"github.com/fernet/fernet-go"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// Define the parameters we use for Argon2id
|
|
type argon2Params struct {
|
|
memory uint32
|
|
iterations uint32
|
|
parallelism uint8
|
|
saltLength uint32
|
|
keyLength uint32
|
|
}
|
|
|
|
// AuthMiddleware creates a middleware for authentication
|
|
func AuthMiddleware(db *db.PostgresDB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
|
|
// Get the username from the URL parameters
|
|
username := c.Param("username")
|
|
if username == "" {
|
|
log.Printf("[ERROR] AuthMiddleware: Username parameter is missing in path")
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Check if this is an internal API call via X-GPodder-Token
|
|
gpodderTokenHeader := c.GetHeader("X-GPodder-Token")
|
|
if gpodderTokenHeader != "" {
|
|
|
|
// Get user data
|
|
var userID int
|
|
var gpodderToken sql.NullString
|
|
var podSyncType string
|
|
|
|
err := db.QueryRow(`
|
|
SELECT UserID, GpodderToken, Pod_Sync_Type FROM "Users"
|
|
WHERE LOWER(Username) = LOWER($1)
|
|
`, username).Scan(&userID, &gpodderToken, &podSyncType)
|
|
|
|
if err != nil {
|
|
log.Printf("[ERROR] AuthMiddleware: Database error: %v", err)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or token"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Check if gpodder sync is enabled
|
|
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
|
|
log.Printf("[ERROR] AuthMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
|
|
username, podSyncType)
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// For internal calls with X-GPodder-Token header, validate token directly
|
|
if gpodderToken.Valid && gpodderToken.String == gpodderTokenHeader {
|
|
c.Set("userID", userID)
|
|
c.Set("username", username)
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// If token doesn't match, authentication failed
|
|
log.Printf("[ERROR] AuthMiddleware: Invalid X-GPodder-Token for user: %s", username)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// If no token header found, proceed with standard authentication
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
log.Printf("[ERROR] AuthMiddleware: Authorization header is missing")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Extract credentials
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) != 2 || parts[0] != "Basic" {
|
|
log.Printf("[ERROR] AuthMiddleware: Invalid Authorization header format")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Decode credentials
|
|
decoded, err := base64.StdEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
log.Printf("[ERROR] AuthMiddleware: Failed to decode base64 credentials: %v", err)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Extract username and password
|
|
credentials := strings.SplitN(string(decoded), ":", 2)
|
|
if len(credentials) != 2 {
|
|
log.Printf("[ERROR] AuthMiddleware: Invalid credentials format")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
authUsername := credentials[0]
|
|
password := credentials[1]
|
|
|
|
// Check username match
|
|
if strings.ToLower(username) != strings.ToLower(authUsername) {
|
|
log.Printf("[ERROR] AuthMiddleware: Username mismatch - URL: %s, Auth: %s",
|
|
strings.ToLower(username), strings.ToLower(authUsername))
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Query user data
|
|
var userID int
|
|
var hashedPassword string
|
|
var podSyncType string
|
|
var gpodderToken sql.NullString
|
|
|
|
err = db.QueryRow(`
|
|
SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM "Users"
|
|
WHERE LOWER(Username) = LOWER($1)
|
|
`, username).Scan(&userID, &hashedPassword, &podSyncType, &gpodderToken)
|
|
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
log.Printf("[ERROR] AuthMiddleware: User not found: %s", username)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
|
} else {
|
|
log.Printf("[ERROR] AuthMiddleware: Database error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
}
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Check if gpodder sync is enabled
|
|
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
|
|
log.Printf("[ERROR] AuthMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
|
|
username, podSyncType)
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Flag to track authentication success
|
|
authenticated := false
|
|
|
|
// Check if this is a gpodder token authentication
|
|
// Check if this is a gpodder token authentication
|
|
if gpodderToken.Valid && (gpodderToken.String == password || gpodderToken.String == gpodderTokenHeader) {
|
|
authenticated = true
|
|
}
|
|
|
|
// If token auth didn't succeed, try password authentication
|
|
if !authenticated && verifyPassword(password, hashedPassword) {
|
|
authenticated = true
|
|
}
|
|
|
|
// If authentication was successful, set context and continue
|
|
if authenticated {
|
|
c.Set("userID", userID)
|
|
c.Set("username", username)
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Authentication failed
|
|
log.Printf("[ERROR] AuthMiddleware: Invalid credentials for user: %s", username)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
|
c.Abort()
|
|
}
|
|
}
|
|
|
|
// Helper function to decrypt token
|
|
func decryptToken(encryptionKey []byte, encryptedToken string) (string, error) {
|
|
// Ensure the encryptionKey is correctly formatted for fernet
|
|
// Fernet requires a 32-byte key encoded in base64
|
|
keyStr := base64.StdEncoding.EncodeToString(encryptionKey)
|
|
|
|
// Parse the key
|
|
key, err := fernet.DecodeKey(keyStr)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to decode key: %w", err)
|
|
}
|
|
|
|
// Decrypt the token
|
|
token := []byte(encryptedToken)
|
|
msg := fernet.VerifyAndDecrypt(token, 0, []*fernet.Key{key})
|
|
if msg == nil {
|
|
return "", fmt.Errorf("failed to decrypt token or token invalid")
|
|
}
|
|
|
|
return string(msg), nil
|
|
}
|
|
|
|
// generateSessionToken generates a random token for sessions
|
|
func generateSessionToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
_, err := rand.Read(b)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.URLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
// createSession creates a new session in the database
|
|
func createSession(db *db.Database, userID int, userAgent, clientIP string) (string, time.Time, error) {
|
|
// Generate a random session token
|
|
token, err := generateSessionToken()
|
|
if err != nil {
|
|
return "", time.Time{}, fmt.Errorf("failed to generate session token: %w", err)
|
|
}
|
|
|
|
// Set expiration time (30 days from now)
|
|
expires := time.Now().Add(30 * 24 * time.Hour)
|
|
|
|
// Insert session into database
|
|
_, err = db.Exec(`
|
|
INSERT INTO "GpodderSessions" (UserID, SessionToken, ExpiresAt, UserAgent, ClientIP)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`, userID, token, expires, userAgent, clientIP)
|
|
|
|
if err != nil {
|
|
return "", time.Time{}, fmt.Errorf("failed to create session: %w", err)
|
|
}
|
|
|
|
return token, expires, nil
|
|
}
|
|
|
|
// validateSession validates a session token
|
|
func validateSession(db *db.Database, token string) (int, bool, error) {
|
|
var userID int
|
|
var expires time.Time
|
|
var query string
|
|
|
|
// Format query according to database type
|
|
if db.IsPostgreSQLDB() {
|
|
query = `SELECT UserID, ExpiresAt FROM "GpodderSessions" WHERE SessionToken = $1`
|
|
} else {
|
|
query = `SELECT UserID, ExpiresAt FROM GpodderSessions WHERE SessionToken = ?`
|
|
}
|
|
|
|
err := db.QueryRow(query, token).Scan(&userID, &expires)
|
|
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return 0, false, nil // Session not found
|
|
}
|
|
return 0, false, fmt.Errorf("error validating session: %w", err)
|
|
}
|
|
|
|
// Check if session has expired
|
|
if time.Now().After(expires) {
|
|
// Delete expired session
|
|
if db.IsPostgreSQLDB() {
|
|
query = `DELETE FROM "GpodderSessions" WHERE SessionToken = $1`
|
|
} else {
|
|
query = `DELETE FROM GpodderSessions WHERE SessionToken = ?`
|
|
}
|
|
|
|
_, err = db.Exec(query, token)
|
|
if err != nil {
|
|
log.Printf("Failed to delete expired session: %v", err)
|
|
}
|
|
return 0, false, nil
|
|
}
|
|
|
|
// Update last active time
|
|
if db.IsPostgreSQLDB() {
|
|
query = `UPDATE "GpodderSessions" SET LastActive = CURRENT_TIMESTAMP WHERE SessionToken = $1`
|
|
} else {
|
|
query = `UPDATE GpodderSessions SET LastActive = CURRENT_TIMESTAMP WHERE SessionToken = ?`
|
|
}
|
|
|
|
_, err = db.Exec(query, token)
|
|
|
|
if err != nil {
|
|
log.Printf("Failed to update session last active time: %v", err)
|
|
}
|
|
|
|
return userID, true, nil
|
|
}
|
|
|
|
// deleteSession removes a session from the database
|
|
func deleteSession(db *db.Database, token string) error {
|
|
_, err := db.Exec(`DELETE FROM "GpodderSessions" WHERE SessionToken = $1`, token)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deleteUserSessions removes all sessions for a user
|
|
func deleteUserSessions(db *db.PostgresDB, userID int) error {
|
|
_, err := db.Exec(`DELETE FROM "GpodderSessions" WHERE UserID = $1`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete user sessions: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleLogin enhanced with session management
|
|
func handleLogin(database *db.Database) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Use the AuthMiddleware to authenticate the user
|
|
username := c.Param("username")
|
|
if username == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
|
|
return
|
|
}
|
|
|
|
// Get the Authorization header
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
|
|
return
|
|
}
|
|
|
|
// Check if the Authorization header is in the correct format
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) != 2 || parts[0] != "Basic" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
|
|
return
|
|
}
|
|
|
|
// Decode the base64-encoded credentials
|
|
decoded, err := base64.StdEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"})
|
|
return
|
|
}
|
|
|
|
// Extract username and password
|
|
credentials := strings.SplitN(string(decoded), ":", 2)
|
|
if len(credentials) != 2 {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"})
|
|
return
|
|
}
|
|
|
|
authUsername := credentials[0]
|
|
password := credentials[1]
|
|
|
|
// Verify that the username in the URL matches the one in the Authorization header
|
|
if strings.ToLower(username) != strings.ToLower(authUsername) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"})
|
|
return
|
|
}
|
|
|
|
// Check if the user exists and the password is correct
|
|
var userID int
|
|
var hashedPassword string
|
|
var podSyncType string
|
|
|
|
// Make sure to use case-insensitive username lookup
|
|
err = database.QueryRow(`
|
|
SELECT UserID, Hashed_PW, Pod_Sync_Type FROM "Users" WHERE LOWER(Username) = LOWER($1)
|
|
`, username).Scan(&userID, &hashedPassword, &podSyncType)
|
|
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
|
} else {
|
|
log.Printf("Database error during login: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check if gpodder sync is enabled for this user
|
|
if podSyncType != "gpodder" && podSyncType != "both" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
|
return
|
|
}
|
|
|
|
// Verify password using Pinepods' Argon2 password method
|
|
if !verifyPassword(password, hashedPassword) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
|
return
|
|
}
|
|
|
|
// Create a new session
|
|
userAgent := c.Request.UserAgent()
|
|
clientIP := c.ClientIP()
|
|
sessionToken, expiresAt, err := createSession(database, userID, userAgent, clientIP)
|
|
|
|
if err != nil {
|
|
log.Printf("Failed to create session: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
|
return
|
|
}
|
|
|
|
log.Printf("[DEBUG] handleLogin: Login successful for user: %s, created session token (first 8 chars): %s...",
|
|
username, sessionToken[:8])
|
|
|
|
// Set session cookie
|
|
c.SetCookie(
|
|
"sessionid", // name
|
|
sessionToken, // value
|
|
int(30*24*time.Hour.Seconds()), // max age in seconds (30 days)
|
|
"/", // path
|
|
"", // domain (empty = current domain)
|
|
c.Request.TLS != nil, // secure (HTTPS only)
|
|
true, // httpOnly (not accessible via JavaScript)
|
|
)
|
|
|
|
log.Printf("[DEBUG] handleLogin: Sending response with session expiry: %s",
|
|
expiresAt.Format(time.RFC3339))
|
|
// Return success with info
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "success",
|
|
"userid": userID,
|
|
"username": username,
|
|
"session_expires": expiresAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleLogout enhanced with session management
|
|
func handleLogout(database *db.Database) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Get username from URL
|
|
username := c.Param("username")
|
|
if username == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
|
|
return
|
|
}
|
|
|
|
// Get the session cookie
|
|
sessionToken, err := c.Cookie("sessionid")
|
|
if err != nil || sessionToken == "" {
|
|
// No session cookie, just return success (idempotent operation)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "logged out",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Delete the session
|
|
err = deleteSession(database, sessionToken)
|
|
if err != nil {
|
|
log.Printf("Error deleting session: %v", err)
|
|
// Continue anyway - we still want to invalidate the cookie
|
|
}
|
|
|
|
// Clear the session cookie
|
|
c.SetCookie(
|
|
"sessionid", // name
|
|
"", // value (empty = delete)
|
|
-1, // max age (negative = delete)
|
|
"/", // path
|
|
"", // domain
|
|
c.Request.TLS != nil, // secure
|
|
true, // httpOnly
|
|
)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "logged out",
|
|
})
|
|
}
|
|
}
|
|
|
|
// SessionMiddleware checks if a user is logged in via session
|
|
func SessionMiddleware(database *db.Database) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
log.Printf("[DEBUG] SessionMiddleware processing request: %s %s",
|
|
c.Request.Method, c.Request.URL.Path)
|
|
|
|
// First, try to get user from Authorization header for direct API access
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader != "" {
|
|
log.Printf("[DEBUG] SessionMiddleware: Authorization header found, passing to next middleware")
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// No Authorization header, check for session cookie
|
|
sessionToken, err := c.Cookie("sessionid")
|
|
if err != nil || sessionToken == "" {
|
|
log.Printf("[ERROR] SessionMiddleware: No session cookie found: %v", err)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
log.Printf("[DEBUG] SessionMiddleware: Found session cookie, validating")
|
|
|
|
// Validate the session
|
|
userID, valid, err := validateSession(database, sessionToken)
|
|
if err != nil {
|
|
log.Printf("[ERROR] SessionMiddleware: Error validating session: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Session error"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
if !valid {
|
|
log.Printf("[ERROR] SessionMiddleware: Invalid or expired session")
|
|
// Clear the invalid cookie
|
|
c.SetCookie("sessionid", "", -1, "/", "", c.Request.TLS != nil, true)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
log.Printf("[DEBUG] SessionMiddleware: Session valid for userID: %d", userID)
|
|
|
|
// Get the username for the user ID
|
|
var username string
|
|
err = database.QueryRow(`SELECT Username FROM "Users" WHERE UserID = $1`, userID).Scan(&username)
|
|
if err != nil {
|
|
log.Printf("[ERROR] SessionMiddleware: Error getting username for userID %d: %v",
|
|
userID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "User data error"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Check if gpodder sync is enabled for this user
|
|
var podSyncType string
|
|
err = database.QueryRow(`SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1`, userID).Scan(&podSyncType)
|
|
if err != nil {
|
|
log.Printf("[ERROR] SessionMiddleware: Error checking sync type for userID %d: %v",
|
|
userID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "User data error"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
if podSyncType != "gpodder" && podSyncType != "both" {
|
|
log.Printf("[ERROR] SessionMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
|
|
username, podSyncType)
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Set the user information in the context
|
|
c.Set("userID", userID)
|
|
c.Set("username", username)
|
|
|
|
// Check if the path username matches the session username
|
|
pathUsername := c.Param("username")
|
|
if pathUsername != "" && strings.ToLower(pathUsername) != strings.ToLower(username) {
|
|
log.Printf("[ERROR] SessionMiddleware: Username mismatch - Path: %s, Session: %s",
|
|
pathUsername, username)
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Username mismatch"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
log.Printf("[DEBUG] SessionMiddleware: Session authentication successful for user: %s", username)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// AuthenticationMiddleware with GPodder token handling
|
|
func AuthenticationMiddleware(database *db.Database) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
log.Printf("[DEBUG] AuthenticationMiddleware processing request: %s %s",
|
|
c.Request.Method, c.Request.URL.Path)
|
|
|
|
// Handle GPodder API standard .json suffix patterns
|
|
if strings.HasSuffix(c.Request.URL.Path, ".json") {
|
|
parts := strings.Split(c.Request.URL.Path, "/")
|
|
var username string
|
|
|
|
// Handle /episodes/username.json pattern
|
|
if strings.Contains(c.Request.URL.Path, "/episodes/") && len(parts) >= 3 {
|
|
usernameWithExt := parts[len(parts)-1]
|
|
username = strings.TrimSuffix(usernameWithExt, ".json")
|
|
log.Printf("[DEBUG] AuthenticationMiddleware: Extracted username '%s' from episode actions URL", username)
|
|
}
|
|
|
|
// Handle /devices/username.json pattern
|
|
if strings.Contains(c.Request.URL.Path, "/devices/") {
|
|
for i, part := range parts {
|
|
if part == "devices" && i+1 < len(parts) {
|
|
usernameWithExt := parts[i+1]
|
|
username = strings.TrimSuffix(usernameWithExt, ".json")
|
|
log.Printf("[DEBUG] AuthenticationMiddleware: Extracted username '%s' from devices URL", username)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set username parameter if extracted
|
|
if username != "" {
|
|
c.Params = append(c.Params, gin.Param{Key: "username", Value: username})
|
|
}
|
|
}
|
|
|
|
// First try session auth
|
|
sessionToken, err := c.Cookie("sessionid")
|
|
if err == nil && sessionToken != "" {
|
|
log.Printf("[DEBUG] AuthenticationMiddleware: Found session cookie, validating")
|
|
|
|
userID, valid, err := validateSession(database, sessionToken)
|
|
if err == nil && valid {
|
|
log.Printf("[DEBUG] AuthenticationMiddleware: Session valid for userID: %d", userID)
|
|
|
|
var username string
|
|
var query string
|
|
|
|
// Format query according to database type
|
|
if database.IsPostgreSQLDB() {
|
|
query = `SELECT Username FROM "Users" WHERE UserID = $1`
|
|
} else {
|
|
query = `SELECT Username FROM Users WHERE UserID = ?`
|
|
}
|
|
|
|
err = database.QueryRow(query, userID).Scan(&username)
|
|
if err == nil {
|
|
var podSyncType string
|
|
|
|
// Format query according to database type
|
|
if database.IsPostgreSQLDB() {
|
|
query = `SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1`
|
|
} else {
|
|
query = `SELECT Pod_Sync_Type FROM Users WHERE UserID = ?`
|
|
}
|
|
|
|
err = database.QueryRow(query, userID).Scan(&podSyncType)
|
|
|
|
if err == nil && (podSyncType == "gpodder" || podSyncType == "both") {
|
|
// Check if the path username matches the session username
|
|
pathUsername := c.Param("username")
|
|
if pathUsername == "" || strings.ToLower(pathUsername) == strings.ToLower(username) {
|
|
log.Printf("[DEBUG] AuthenticationMiddleware: Session auth successful for user: %s",
|
|
username)
|
|
c.Set("userID", userID)
|
|
c.Set("username", username)
|
|
c.Next()
|
|
return
|
|
} else {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Session username mismatch - Path: %s, Session: %s",
|
|
pathUsername, username)
|
|
}
|
|
} else {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Gpodder not enabled for user: %s", username)
|
|
}
|
|
} else {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Could not get username for userID %d: %v",
|
|
userID, err)
|
|
}
|
|
} else {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Invalid session: %v", err)
|
|
}
|
|
} else {
|
|
log.Printf("[DEBUG] AuthenticationMiddleware: No session cookie, falling back to basic auth")
|
|
}
|
|
|
|
// Try basic auth if session auth failed
|
|
log.Printf("[DEBUG] AuthenticationMiddleware: Attempting basic auth")
|
|
|
|
username := c.Param("username")
|
|
if username == "" {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Username parameter is missing in path")
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
|
|
return
|
|
}
|
|
|
|
// Check if this is an internal API call via X-GPodder-Token
|
|
gpodderTokenHeader := c.GetHeader("X-GPodder-Token")
|
|
if gpodderTokenHeader != "" {
|
|
log.Printf("[DEBUG] AuthenticationMiddleware: Found X-GPodder-Token header")
|
|
|
|
// Get user data
|
|
var userID int
|
|
var gpodderToken sql.NullString
|
|
var podSyncType string
|
|
var query string
|
|
|
|
// Format query according to database type
|
|
if database.IsPostgreSQLDB() {
|
|
query = `SELECT UserID, GpodderToken, Pod_Sync_Type FROM "Users"
|
|
WHERE LOWER(Username) = LOWER($1)`
|
|
} else {
|
|
query = `SELECT UserID, GpodderToken, Pod_Sync_Type FROM Users
|
|
WHERE LOWER(Username) = LOWER(?)`
|
|
}
|
|
|
|
err := database.QueryRow(query, username).Scan(&userID, &gpodderToken, &podSyncType)
|
|
|
|
if err != nil {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Database error: %v", err)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or token"})
|
|
return
|
|
}
|
|
|
|
// Check if gpodder sync is enabled
|
|
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
|
|
username, podSyncType)
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
|
return
|
|
}
|
|
|
|
// For internal calls with X-GPodder-Token header, validate token directly
|
|
if gpodderToken.Valid && gpodderToken.String == gpodderTokenHeader {
|
|
log.Printf("[DEBUG] AuthenticationMiddleware: X-GPodder-Token validated for user: %s", username)
|
|
c.Set("userID", userID)
|
|
c.Set("username", username)
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// If token doesn't match, authentication failed
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Invalid X-GPodder-Token for user: %s", username)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
|
return
|
|
}
|
|
|
|
// Standard basic auth handling
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: No Authorization header found")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
|
|
return
|
|
}
|
|
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) != 2 || parts[0] != "Basic" {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Invalid Authorization header format")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
|
|
return
|
|
}
|
|
|
|
decoded, err := base64.StdEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Failed to decode base64 credentials: %v", err)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"})
|
|
return
|
|
}
|
|
|
|
credentials := strings.SplitN(string(decoded), ":", 2)
|
|
if len(credentials) != 2 {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Invalid credentials format")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"})
|
|
return
|
|
}
|
|
|
|
authUsername := credentials[0]
|
|
password := credentials[1]
|
|
|
|
if strings.ToLower(username) != strings.ToLower(authUsername) {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Username mismatch - URL: %s, Auth: %s",
|
|
strings.ToLower(username), strings.ToLower(authUsername))
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"})
|
|
return
|
|
}
|
|
|
|
var userID int
|
|
var hashedPassword string
|
|
var podSyncType string
|
|
var gpodderToken sql.NullString
|
|
var query string
|
|
|
|
// Format query according to database type
|
|
if database.IsPostgreSQLDB() {
|
|
query = `SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM "Users"
|
|
WHERE LOWER(Username) = LOWER($1)`
|
|
} else {
|
|
query = `SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM Users
|
|
WHERE LOWER(Username) = LOWER(?)`
|
|
}
|
|
|
|
err = database.QueryRow(query, username).Scan(&userID, &hashedPassword, &podSyncType, &gpodderToken)
|
|
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: User not found: %s", username)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
|
} else {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Database error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
}
|
|
return
|
|
}
|
|
|
|
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
|
|
username, podSyncType)
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
|
return
|
|
}
|
|
|
|
// Flag to track authentication success
|
|
authenticated := false
|
|
|
|
// Check if this is a gpodder token authentication
|
|
if gpodderToken.Valid && gpodderToken.String == password {
|
|
log.Printf("[DEBUG] AuthenticationMiddleware: User authenticated with gpodder token: %s", username)
|
|
authenticated = true
|
|
}
|
|
|
|
// If token auth didn't succeed, try password authentication
|
|
if !authenticated && verifyPassword(password, hashedPassword) {
|
|
log.Printf("[DEBUG] AuthenticationMiddleware: User authenticated with password: %s", username)
|
|
authenticated = true
|
|
}
|
|
|
|
// If authentication was successful, set context and continue
|
|
if authenticated {
|
|
c.Set("userID", userID)
|
|
c.Set("username", username)
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Authentication failed
|
|
log.Printf("[ERROR] AuthenticationMiddleware: Invalid credentials for user: %s", username)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
|
}
|
|
}
|
|
|
|
// verifyPassword verifies a password against a hash using Argon2
|
|
// This implementation matches the Pinepods authentication mechanism using alexedwards/argon2id
|
|
func verifyPassword(password, hashedPassword string) bool {
|
|
// Use the alexedwards/argon2id package to compare password and hash
|
|
match, err := argon2id.ComparePasswordAndHash(password, hashedPassword)
|
|
if err != nil {
|
|
// Log the error in a production environment
|
|
return false
|
|
}
|
|
|
|
return match
|
|
}
|