// backend\server.go package main import ( "context" "crypto/subtle" "errors" "net/http" "strings" "sync" "time" "github.com/golang-jwt/jwt/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" ) type contextKey string const ( userContextKey contextKey = "user" sessionContextKey contextKey = "session" ) type Server struct { db *pgxpool.Pool jwtSecret []byte frontendOrigin string presenceMu sync.Mutex presenceStatus map[string]string } type User struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"displayName"` Email string `json:"email"` Avatar string `json:"avatar"` Unit string `json:"unit"` Group string `json:"group"` Rights []string `json:"rights"` Teams []Team `json:"teams"` Online bool `json:"onlineStatus"` PresenceMode string `json:"presenceMode"` PresenceStatus string `json:"presenceStatus"` AwayAfterMinutes int `json:"awayAfterMinutes"` ShowLastSeen bool `json:"showLastSeen"` LastSeenAt *time.Time `json:"lastSeenAt"` TokenVersion int `json:"-"` } type Team struct { ID string `json:"id"` Name string `json:"name"` Initial string `json:"initial"` Href string `json:"href"` Current bool `json:"current"` } type ClientSessionInfoRequest struct { Browser string `json:"browser"` OperatingSystem string `json:"operatingSystem"` DeviceType string `json:"deviceType"` } type RegisterRequest struct { Username string `json:"username"` DisplayName string `json:"displayName"` Email string `json:"email"` Password string `json:"password"` Avatar string `json:"avatar"` Unit string `json:"unit"` Group string `json:"group"` Rights []string `json:"rights"` SessionInfo ClientSessionInfoRequest `json:"sessionInfo"` } type LoginRequest struct { Identifier string `json:"identifier"` Password string `json:"password"` SessionInfo ClientSessionInfoRequest `json:"sessionInfo"` } type Claims struct { UserID string `json:"user_id"` Email string `json:"email"` TokenVersion int `json:"token_version"` SessionID string `json:"session_id"` jwt.RegisteredClaims } func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Server { return &Server{ db: db, jwtSecret: []byte(jwtSecret), frontendOrigin: frontendOrigin, presenceStatus: map[string]string{}, } } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{ "status": "ok", }) } func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { var input RegisterRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } username := normalizeUsername(input.Username) email := normalizeEmail(input.Email) if username == "" || email == "" || len(input.Password) < 8 { writeError(w, http.StatusBadRequest, "Benutzername, E-Mail und Passwort mit mindestens 8 Zeichen sind erforderlich") return } displayName := strings.TrimSpace(input.DisplayName) if displayName == "" { displayName = username } group := strings.TrimSpace(input.Group) if group == "" { group = "user" } rights := input.Rights if len(rights) == 0 { rights = []string{"user"} } passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) if err != nil { writeError(w, http.StatusInternalServerError, "Could not hash password") return } var user User err = s.db.QueryRow( r.Context(), ` INSERT INTO users ( username, display_name, email, password_hash, avatar, unit, user_group, rights ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, COALESCE(username, ''), COALESCE(display_name, ''), email, COALESCE(avatar, ''), COALESCE(unit, ''), COALESCE(user_group, ''), COALESCE(rights, '{}'::TEXT[]), token_version `, username, displayName, email, string(passwordHash), strings.TrimSpace(input.Avatar), strings.TrimSpace(input.Unit), group, rights, ).Scan( &user.ID, &user.Username, &user.DisplayName, &user.Email, &user.Avatar, &user.Unit, &user.Group, &user.Rights, &user.TokenVersion, ) if err != nil { writeError(w, http.StatusConflict, "User already exists") return } sessionID, err := s.createUserSession( r.Context(), user.ID, input.SessionInfo, r, ) if err != nil { writeError(w, http.StatusInternalServerError, "Could not create session") return } user.Online = true user.PresenceMode = "online" user.PresenceStatus = "online" user.AwayAfterMinutes = 3 user.ShowLastSeen = false now := time.Now() user.LastSeenAt = &now if err := s.setSessionCookie(w, user, sessionID); err != nil { writeError(w, http.StatusInternalServerError, "Could not create session") return } writeJSON(w, http.StatusCreated, map[string]any{ "user": user, }) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var input LoginRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } identifier := strings.ToLower(strings.TrimSpace(input.Identifier)) if identifier == "" || input.Password == "" { writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort sind erforderlich") return } user, passwordHash, err := s.getUserByIdentifier(r.Context(), identifier) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusUnauthorized, "Invalid username/email or password") return } if err != nil { writeError(w, http.StatusInternalServerError, "Login failed") return } if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(input.Password)); err != nil { writeError(w, http.StatusUnauthorized, "Invalid username/email or password") return } sessionID, err := s.createUserSession( r.Context(), user.ID, input.SessionInfo, r, ) if err != nil { writeError(w, http.StatusInternalServerError, "Could not create session") return } user.PresenceStatus = user.PresenceMode user.Online = user.PresenceStatus == "online" now := time.Now() user.LastSeenAt = &now if err := s.setSessionCookie(w, user, sessionID); err != nil { writeError(w, http.StatusInternalServerError, "Could not create session") return } writeJSON(w, http.StatusOK, map[string]any{ "user": user, }) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if sessionID, ok := s.currentSessionIDFromCookie(r); ok { _, _ = s.db.Exec( r.Context(), ` WITH revoked_session AS ( UPDATE user_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL RETURNING user_id ) UPDATE users SET last_seen_at = ( SELECT MAX(us.last_seen_at) FROM user_sessions us WHERE us.user_id = revoked_session.user_id AND us.revoked_at IS NULL ) FROM revoked_session WHERE users.id = revoked_session.user_id `, sessionID, ) } http.SetCookie(w, &http.Cookie{ Name: "session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: false, }) writeJSON(w, http.StatusOK, map[string]string{ "message": "Logged out", }) } func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(userContextKey).(User) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } writeJSON(w, http.StatusOK, map[string]any{ "user": user, }) } func (s *Server) setSessionCookie(w http.ResponseWriter, user User, sessionID string) error { now := time.Now() claims := Claims{ UserID: user.ID, Email: user.Email, TokenVersion: user.TokenVersion, SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{ Subject: user.ID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString(s.jwtSecret) if err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: "session", Value: tokenString, Path: "/", MaxAge: 60 * 60 * 24, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: false, }) return nil } func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session") if err != nil { writeError(w, http.StatusUnauthorized, "Unauthorized") return } claims := &Claims{} token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) { if subtle.ConstantTimeCompare([]byte(token.Method.Alg()), []byte(jwt.SigningMethodHS256.Alg())) != 1 { return nil, errors.New("unexpected signing method") } return s.jwtSecret, nil }) if err != nil || !token.Valid { writeError(w, http.StatusUnauthorized, "Unauthorized") return } user, err := s.getUserByID(r.Context(), claims.UserID) if err != nil { writeError(w, http.StatusUnauthorized, "Unauthorized") return } if claims.SessionID == "" { writeError(w, http.StatusUnauthorized, "Unauthorized") return } sessionActive, err := s.isUserSessionActive( r.Context(), user.ID, claims.SessionID, ) if err != nil || !sessionActive { writeError(w, http.StatusUnauthorized, "Unauthorized") return } _ = s.touchUserSession(r.Context(), user.ID, claims.SessionID) user, err = s.getUserByID(r.Context(), user.ID) if err != nil { writeError(w, http.StatusUnauthorized, "Unauthorized") return } ctx := context.WithValue(r.Context(), userContextKey, user) ctx = context.WithValue(ctx, sessionContextKey, claims.SessionID) next(w, r.WithContext(ctx)) } } func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (User, string, error) { var user User var passwordHash string identifier = strings.ToLower(strings.TrimSpace(identifier)) err := s.db.QueryRow( ctx, ` SELECT id, COALESCE(username, ''), COALESCE(display_name, ''), email, password_hash, COALESCE(avatar, ''), COALESCE(unit, ''), COALESCE(user_group, ''), COALESCE(rights, '{}'::TEXT[]), COALESCE(presence_mode, 'online'), away_after_minutes, show_last_seen, `+presenceStatusSQL+`, last_seen_at, token_version FROM users WHERE lower(email) = $1 OR lower(username) = $1 LIMIT 1 `, identifier, ).Scan( &user.ID, &user.Username, &user.DisplayName, &user.Email, &passwordHash, &user.Avatar, &user.Unit, &user.Group, &user.Rights, &user.PresenceMode, &user.AwayAfterMinutes, &user.ShowLastSeen, &user.PresenceStatus, &user.LastSeenAt, &user.TokenVersion, ) user.Online = user.PresenceStatus == "online" if err != nil { return user, passwordHash, err } teams, err := s.getTeamsForUser(ctx, user.ID) if err != nil { return user, passwordHash, err } user.Teams = teams return user, passwordHash, nil } func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) { var user User err := s.db.QueryRow( ctx, ` SELECT id, COALESCE(username, ''), COALESCE(display_name, ''), email, COALESCE(avatar, ''), COALESCE(unit, ''), COALESCE(user_group, ''), COALESCE(rights, '{}'::TEXT[]), COALESCE(presence_mode, 'online'), away_after_minutes, show_last_seen, `+presenceStatusSQL+`, last_seen_at, token_version FROM users WHERE id = $1 LIMIT 1 `, userID, ).Scan( &user.ID, &user.Username, &user.DisplayName, &user.Email, &user.Avatar, &user.Unit, &user.Group, &user.Rights, &user.PresenceMode, &user.AwayAfterMinutes, &user.ShowLastSeen, &user.PresenceStatus, &user.LastSeenAt, &user.TokenVersion, ) user.Online = user.PresenceStatus == "online" if err != nil { return user, err } teams, err := s.getTeamsForUser(ctx, user.ID) if err != nil { return user, err } user.Teams = teams return user, nil } func normalizeUsername(username string) string { return strings.ToLower(strings.TrimSpace(username)) } func (s *Server) getTeamsForUser(ctx context.Context, userID string) ([]Team, error) { rows, err := s.db.Query( ctx, ` SELECT t.id, t.name, t.initial, t.href FROM teams t INNER JOIN user_teams ut ON ut.team_id = t.id WHERE ut.user_id = $1 ORDER BY t.name ASC `, userID, ) if err != nil { return nil, err } defer rows.Close() teams := []Team{} for rows.Next() { var team Team if err := rows.Scan(&team.ID, &team.Name, &team.Initial, &team.Href); err != nil { return nil, err } team.Current = false teams = append(teams, team) } if err := rows.Err(); err != nil { return nil, err } return teams, nil }