teg/backend/server.go
2026-05-12 15:29:56 +02:00

462 lines
9.3 KiB
Go

// 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
}