mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 05:53:15 +02:00
Add optional OIDC authentication with Keycloak, Authentik, and Pocket-ID support
This commit is contained in:
206
internal/auth/session.go
Normal file
206
internal/auth/session.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
|
||||
//
|
||||
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
|
||||
//
|
||||
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
|
||||
// You may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.gnu.org/licenses/gpl-3.0.en.html
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "fail2ban_ui_session"
|
||||
sessionKeyLength = 32 // AES-256
|
||||
)
|
||||
|
||||
// Session represents a user session
|
||||
type Session struct {
|
||||
UserID string `json:"userID"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
var sessionSecret []byte
|
||||
|
||||
// InitializeSessionSecret initializes the session encryption secret
|
||||
func InitializeSessionSecret(secret string) error {
|
||||
if secret == "" {
|
||||
return fmt.Errorf("session secret cannot be empty")
|
||||
}
|
||||
|
||||
// Decode base64 secret or use directly if not base64
|
||||
decoded, err := base64.URLEncoding.DecodeString(secret)
|
||||
if err != nil {
|
||||
// Not base64, use as-is (but ensure it's 32 bytes for AES-256)
|
||||
if len(secret) < sessionKeyLength {
|
||||
return fmt.Errorf("session secret must be at least %d bytes", sessionKeyLength)
|
||||
}
|
||||
// Use first 32 bytes
|
||||
sessionSecret = []byte(secret[:sessionKeyLength])
|
||||
} else {
|
||||
if len(decoded) < sessionKeyLength {
|
||||
return fmt.Errorf("decoded session secret must be at least %d bytes", sessionKeyLength)
|
||||
}
|
||||
sessionSecret = decoded[:sessionKeyLength]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSession creates a new encrypted session cookie
|
||||
func CreateSession(w http.ResponseWriter, r *http.Request, userInfo *UserInfo, maxAge int) error {
|
||||
session := &Session{
|
||||
UserID: userInfo.ID,
|
||||
Email: userInfo.Email,
|
||||
Name: userInfo.Name,
|
||||
Username: userInfo.Username,
|
||||
ExpiresAt: time.Now().Add(time.Duration(maxAge) * time.Second),
|
||||
}
|
||||
|
||||
// Serialize session to JSON
|
||||
sessionData, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal session: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt session data
|
||||
encrypted, err := encrypt(sessionData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt session: %w", err)
|
||||
}
|
||||
|
||||
// Determine if we're using HTTPS
|
||||
isSecure := r != nil && (r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https")
|
||||
|
||||
// Create secure cookie
|
||||
cookie := &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: encrypted,
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: isSecure, // Only secure over HTTPS
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
http.SetCookie(w, cookie)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession retrieves and validates a session from the cookie
|
||||
func GetSession(r *http.Request) (*Session, error) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no session cookie: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt session data
|
||||
decrypted, err := decrypt(cookie.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt session: %w", err)
|
||||
}
|
||||
|
||||
// Deserialize session
|
||||
var session Session
|
||||
if err := json.Unmarshal(decrypted, &session); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
|
||||
}
|
||||
|
||||
// Check if session is expired
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
return nil, fmt.Errorf("session expired")
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// DeleteSession clears the session cookie
|
||||
func DeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
// Determine if we're using HTTPS
|
||||
isSecure := r != nil && (r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https")
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: isSecure, // Only secure over HTTPS
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
// encrypt encrypts data using AES-GCM
|
||||
func encrypt(plaintext []byte) (string, error) {
|
||||
block, err := aes.NewCipher(sessionSecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
return base64.URLEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// decrypt decrypts data using AES-GCM
|
||||
func decrypt(ciphertext string) ([]byte, error) {
|
||||
data, err := base64.URLEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(sessionSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
Reference in New Issue
Block a user