516 lines
10 KiB
Go
516 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.db.Exec(
|
|
r.Context(),
|
|
`DELETE FROM push_subscriptions WHERE session_id = $1`,
|
|
sessionID,
|
|
)
|
|
|
|
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)
|
|
}
|