// 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, ` 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, ` UPDATE user_sessions SET last_seen_at = now() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL `, 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) }