643 lines
14 KiB
Go
643 lines
14 KiB
Go
// backend\server.go
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"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
|
|
vapidPublicKey string
|
|
vapidPrivateKey string
|
|
vapidSubscriber 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 {
|
|
vapidSubscriber := strings.TrimSpace(os.Getenv("VAPID_SUBSCRIBER"))
|
|
if vapidSubscriber == "" {
|
|
if adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")); adminEmail != "" {
|
|
vapidSubscriber = "mailto:" + adminEmail
|
|
}
|
|
}
|
|
|
|
return &Server{
|
|
db: db,
|
|
jwtSecret: []byte(jwtSecret),
|
|
frontendOrigin: frontendOrigin,
|
|
vapidPublicKey: strings.TrimSpace(os.Getenv("VAPID_PUBLIC_KEY")),
|
|
vapidPrivateKey: strings.TrimSpace(os.Getenv("VAPID_PRIVATE_KEY")),
|
|
vapidSubscriber: vapidSubscriber,
|
|
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 {
|
|
// Fehlgeschlagene Logins werden protokolliert. Diese Einträge bleiben
|
|
// erhalten, da das Protokoll nur bei erfolgreichem Login geleert wird.
|
|
s.logUserEvent(r.Context(), UserLogEntry{
|
|
User: &user,
|
|
Request: r,
|
|
Level: UserLogLevelWarning,
|
|
Category: "auth",
|
|
Action: "login",
|
|
Message: "Login fehlgeschlagen: falsches Passwort",
|
|
})
|
|
|
|
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 {
|
|
s.logUserError(
|
|
r,
|
|
user,
|
|
"auth",
|
|
"login",
|
|
"Sitzungs-Cookie konnte nicht erstellt werden",
|
|
err,
|
|
nil,
|
|
)
|
|
writeError(w, http.StatusInternalServerError, "Could not create session")
|
|
return
|
|
}
|
|
|
|
// Erfolgreicher Login: Protokoll des Users vollständig leeren (inklusive des
|
|
// soeben erzeugten Sitzungs-Eintrags), damit es danach komplett leer ist.
|
|
s.deleteUserLogs(r.Context(), user.ID)
|
|
|
|
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(),
|
|
`DELETE FROM push_subscriptions WHERE session_id = $1`,
|
|
sessionID,
|
|
)
|
|
|
|
_, _ = 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
|
|
}
|