teg/backend/user_sessions.go
2026-06-13 10:15:57 +02:00

510 lines
10 KiB
Go

// backend/user_sessions.go
package main
import (
"context"
"encoding/json"
"net"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
type userSessionResponse struct {
ID string `json:"id"`
Browser string `json:"browser"`
OperatingSystem string `json:"operatingSystem"`
DeviceType string `json:"deviceType"`
UserAgent string `json:"userAgent"`
IPAddress string `json:"ipAddress"`
CreatedAt time.Time `json:"createdAt"`
LastSeenAt time.Time `json:"lastSeenAt"`
Current bool `json:"current"`
}
func (s *Server) createUserSession(
ctx context.Context,
userID string,
input ClientSessionInfoRequest,
r *http.Request,
) (string, error) {
browser := strings.TrimSpace(input.Browser)
operatingSystem := strings.TrimSpace(input.OperatingSystem)
deviceType := strings.TrimSpace(input.DeviceType)
userAgent := r.UserAgent()
ipAddress := getRequestIP(r)
if browser == "" {
browser = detectBrowserFromUserAgent(userAgent)
}
if operatingSystem == "" {
operatingSystem = detectOperatingSystemFromUserAgent(userAgent)
}
if deviceType == "" {
deviceType = detectDeviceTypeFromUserAgent(userAgent)
}
var sessionID string
err := s.db.QueryRow(
ctx,
`
WITH touched_user AS (
UPDATE users
SET
last_seen_at = now(),
last_active_at = CASE
WHEN presence_mode = 'online' THEN now()
ELSE last_active_at
END
WHERE id = $1
)
INSERT INTO user_sessions (
user_id,
browser,
operating_system,
device_type,
user_agent,
ip_address
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`,
userID,
browser,
operatingSystem,
deviceType,
userAgent,
ipAddress,
).Scan(&sessionID)
if err != nil {
s.logUserEvent(ctx, UserLogEntry{
UserID: userID,
Request: r,
Level: UserLogLevelError,
Category: "session",
Action: "create_session",
Message: "Sitzung konnte nicht erstellt werden",
Error: err,
Details: LogFields{
"browser": browser,
"operatingSystem": operatingSystem,
"deviceType": deviceType,
"ipAddress": ipAddress,
},
})
return "", err
}
s.logUserEvent(ctx, UserLogEntry{
UserID: userID,
Request: r,
Level: UserLogLevelInfo,
Category: "session",
Action: "create_session",
Message: "Neue Sitzung wurde erstellt",
Details: LogFields{
"sessionId": sessionID,
"browser": browser,
"operatingSystem": operatingSystem,
"deviceType": deviceType,
"ipAddress": ipAddress,
},
})
return sessionID, nil
}
func (s *Server) isUserSessionActive(
ctx context.Context,
userID string,
sessionID string,
) (bool, error) {
var exists bool
err := s.db.QueryRow(
ctx,
`
SELECT EXISTS(
SELECT 1
FROM user_sessions
WHERE id = $1
AND user_id = $2
AND revoked_at IS NULL
)
`,
sessionID,
userID,
).Scan(&exists)
return exists, err
}
func (s *Server) touchUserSession(
ctx context.Context,
userID string,
sessionID string,
) error {
_, err := s.db.Exec(
ctx,
`
WITH touched_session AS (
UPDATE user_sessions
SET last_seen_at = now()
WHERE id = $1
AND user_id = $2
AND revoked_at IS NULL
RETURNING user_id
)
UPDATE users
SET last_seen_at = now()
FROM touched_session
WHERE users.id = touched_session.user_id
`,
sessionID,
userID,
)
return err
}
func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
s.logRequestWarning(
r,
"session",
"list_sessions",
"Sitzungen konnten nicht geladen werden: nicht angemeldet",
nil,
nil,
)
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
currentSessionID, _ := s.currentSessionIDFromRequest(r)
rows, err := s.db.Query(
r.Context(),
`
SELECT
id,
browser,
operating_system,
device_type,
user_agent,
ip_address,
created_at,
last_seen_at
FROM user_sessions
WHERE user_id = $1
AND revoked_at IS NULL
ORDER BY
CASE WHEN id = $2 THEN 0 ELSE 1 END,
last_seen_at DESC
`,
userID,
currentSessionID,
)
if err != nil {
s.logAndWriteSettingsError(
w,
r,
http.StatusInternalServerError,
"session",
"list_sessions",
"Sitzungen konnten nicht geladen werden",
err,
LogFields{
"userId": userID,
"currentSessionId": currentSessionID,
},
)
return
}
defer rows.Close()
sessions := []userSessionResponse{}
for rows.Next() {
var session userSessionResponse
if err := rows.Scan(
&session.ID,
&session.Browser,
&session.OperatingSystem,
&session.DeviceType,
&session.UserAgent,
&session.IPAddress,
&session.CreatedAt,
&session.LastSeenAt,
); err != nil {
s.logAndWriteSettingsError(
w,
r,
http.StatusInternalServerError,
"session",
"scan_sessions",
"Sitzungen konnten nicht gelesen werden",
err,
LogFields{
"userId": userID,
},
)
return
}
session.Current = session.ID == currentSessionID
sessions = append(sessions, session)
}
if err := rows.Err(); err != nil {
s.logAndWriteSettingsError(
w,
r,
http.StatusInternalServerError,
"session",
"read_sessions",
"Sitzungen konnten nicht gelesen werden",
err,
LogFields{
"userId": userID,
},
)
return
}
s.logRequestInfo(
r,
"session",
"list_sessions",
"Sitzungen wurden geladen",
LogFields{
"userId": userID,
"sessionCount": len(sessions),
},
)
writeSettingsJSON(w, http.StatusOK, map[string]any{
"sessions": sessions,
})
}
func (s *Server) handleRevokeUserSession(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
s.logRequestWarning(
r,
"session",
"revoke_session",
"Sitzung konnte nicht abgemeldet werden: nicht angemeldet",
nil,
nil,
)
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
sessionID := strings.TrimSpace(r.PathValue("id"))
currentSessionID, _ := s.currentSessionIDFromRequest(r)
if sessionID == "" {
s.logRequestWarning(
r,
"session",
"revoke_session",
"Sitzung konnte nicht abgemeldet werden: Sitzung fehlt",
nil,
LogFields{
"userId": userID,
},
)
writeSettingsError(w, http.StatusBadRequest, "Sitzung fehlt")
return
}
if sessionID == currentSessionID {
s.logRequestWarning(
r,
"session",
"revoke_session",
"Aktuelle Sitzung kann hier nicht abgemeldet werden",
nil,
LogFields{
"userId": userID,
"sessionId": sessionID,
"currentSessionId": currentSessionID,
},
)
writeSettingsError(w, http.StatusBadRequest, "Die aktuelle Sitzung kann hier nicht abgemeldet werden")
return
}
commandTag, err := s.db.Exec(
r.Context(),
`
UPDATE user_sessions
SET revoked_at = now()
WHERE id = $1
AND user_id = $2
AND revoked_at IS NULL
`,
sessionID,
userID,
)
if err != nil {
s.logAndWriteSettingsError(
w,
r,
http.StatusInternalServerError,
"session",
"revoke_session",
"Sitzung konnte nicht abgemeldet werden",
err,
LogFields{
"userId": userID,
"sessionId": sessionID,
},
)
return
}
if commandTag.RowsAffected() == 0 {
s.logRequestWarning(
r,
"session",
"revoke_session",
"Sitzung wurde nicht gefunden",
nil,
LogFields{
"userId": userID,
"sessionId": sessionID,
},
)
writeSettingsError(w, http.StatusNotFound, "Sitzung wurde nicht gefunden")
return
}
s.logRequestInfo(
r,
"session",
"revoke_session",
"Sitzung wurde abgemeldet",
LogFields{
"userId": userID,
"sessionId": sessionID,
},
)
writeSettingsJSON(w, http.StatusOK, map[string]any{
"message": "Sitzung wurde abgemeldet",
})
}
func (s *Server) currentSessionIDFromRequest(r *http.Request) (string, bool) {
sessionID, ok := r.Context().Value(sessionContextKey).(string)
return sessionID, ok && sessionID != ""
}
func (s *Server) currentSessionIDFromCookie(r *http.Request) (string, bool) {
cookie, err := r.Cookie("session")
if err != nil {
return "", false
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) {
return s.jwtSecret, nil
})
if err != nil || !token.Valid || claims.SessionID == "" {
return "", false
}
return claims.SessionID, true
}
func getRequestIP(r *http.Request) string {
forwardedFor := r.Header.Get("X-Forwarded-For")
if forwardedFor != "" {
parts := strings.Split(forwardedFor, ",")
return strings.TrimSpace(parts[0])
}
realIP := r.Header.Get("X-Real-IP")
if realIP != "" {
return strings.TrimSpace(realIP)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return r.RemoteAddr
}
func detectBrowserFromUserAgent(userAgent string) string {
switch {
case strings.Contains(userAgent, "Edg/"):
return "Microsoft Edge"
case strings.Contains(userAgent, "Chrome/") && !strings.Contains(userAgent, "Edg/"):
return "Google Chrome"
case strings.Contains(userAgent, "Firefox/"):
return "Mozilla Firefox"
case strings.Contains(userAgent, "Safari/") && !strings.Contains(userAgent, "Chrome/"):
return "Safari"
default:
return "Unbekannter Browser"
}
}
func detectOperatingSystemFromUserAgent(userAgent string) string {
switch {
case strings.Contains(userAgent, "Windows"):
return "Windows"
case strings.Contains(userAgent, "Mac OS X"):
return "macOS"
case strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "iPad"):
return "iOS"
case strings.Contains(userAgent, "Android"):
return "Android"
case strings.Contains(userAgent, "Linux"):
return "Linux"
default:
return "Unbekanntes Betriebssystem"
}
}
func detectDeviceTypeFromUserAgent(userAgent string) string {
switch {
case strings.Contains(userAgent, "iPhone") ||
(strings.Contains(userAgent, "Android") && strings.Contains(userAgent, "Mobile")):
return "Smartphone"
case strings.Contains(userAgent, "iPad") || strings.Contains(userAgent, "Tablet"):
return "Tablet"
default:
return "Desktop"
}
}
func writeSessionJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}