// backend\server.go package main import ( "context" "crypto/subtle" "errors" "net/http" "strings" "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" type Server struct { db *pgxpool.Pool jwtSecret []byte frontendOrigin 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"` } type Team struct { ID string `json:"id"` Name string `json:"name"` Initial string `json:"initial"` Href string `json:"href"` Current bool `json:"current"` } 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"` } type LoginRequest struct { Identifier string `json:"identifier"` Password string `json:"password"` } type Claims struct { UserID string `json:"user_id"` Email string `json:"email"` jwt.RegisteredClaims } func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Server { return &Server{ db: db, jwtSecret: []byte(jwtSecret), frontendOrigin: frontendOrigin, } } 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[]) `, 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, ) if err != nil { writeError(w, http.StatusConflict, "User already exists") return } if err := s.setSessionCookie(w, user); 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 } if err := s.setSessionCookie(w, user); 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) { 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) error { now := time.Now() claims := Claims{ UserID: user.ID, Email: user.Email, 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, // In Produktion auf true setzen, wenn HTTPS aktiv ist. 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 } ctx := context.WithValue(r.Context(), userContextKey, user) next(w, r.WithContext(ctx)) } } func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (User, string, error) { var user User var passwordHash string 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[]) 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, ) 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[]) 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, ) 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 }