feat: add server foundation (config, database, auth, main)

- config: Load from env vars (SERVER_PORT, DATABASE_URL, JWT_SECRET, CAS_DIR, etc.)
- database: pgx/v5 connection pool, models (User, YggdrasilSession, Modpack, GlobalFile, LauncherRelease)
- auth: Yggdrasil endpoints (authenticate, refresh, validate) with SHA-256 password hashing, token rotation
- main: graceful shutdown, HTTP server on configured port
- go.mod: module gitea.mrixs.me/Mrixs/MrixsCraft-server, pgx/v5 dependency

Co-Authored-By: OWL <noreply@anthropic.com>
This commit is contained in:
2026-05-26 13:03:21 +03:00
parent 551c75a232
commit aa7d3a8509
5 changed files with 506 additions and 13 deletions

View File

@@ -1,2 +1,288 @@
// Package auth implements Yggdrasil authentication protocol.
// package auth implements the Yggdrasil authentication protocol.
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
)
// Handler serves Yggdrasil endpoints.
type Handler struct {
db *database.DB
cfg *config.Config
}
// NewHandler creates a new auth handler.
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
return &Handler{db: db, cfg: cfg}
}
// RegisterRoutes mounts the Yggdrasil endpoints on the given mux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /authserver/authenticate", h.authenticate)
mux.HandleFunc("POST /authserver/refresh", h.refresh)
mux.HandleFunc("POST /authserver/validate", h.validate)
}
// ── Request / Response types ──────────────────────────────────
type authenticateRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type authenticateResponse struct {
AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"`
AvailableProfile []profile `json:"availableProfiles"`
SelectedProfile *profile `json:"selectedProfile,omitempty"`
User *userProperties `json:"user,omitempty"`
}
type profile struct {
ID string `json:"id"`
Name string `json:"name"`
}
type userProperties struct {
ID string `json:"id"`
Properties []property `json:"properties"`
}
type property struct {
Name string `json:"name"`
Value string `json:"value"`
}
type refreshRequest struct {
AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"`
}
type refreshResponse struct {
AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"`
SelectedProfile *profile `json:"selectedProfile"`
}
type errorResponse struct {
Error string `json:"error"`
ErrorMessage string `json:"errorMessage"`
}
// ── Handlers ──────────────────────────────────────────────────
func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "Bad Request", "Cannot read body")
return
}
var req authenticateRequest
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "Bad Request", "Invalid JSON")
return
}
// Look up user by username or email.
user, err := h.findUser(r.Context(), req.Username)
if err != nil {
writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid credentials")
return
}
// Verify password (SHA-256 hex comparison).
if !verifyPassword(req.Password, user.PasswordHash) {
writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid credentials")
return
}
// Generate tokens.
accessToken := generateToken()
clientToken := generateToken()
// Store session.
expiresAt := time.Now().Add(24 * time.Hour)
_, err = h.db.Pool().Exec(r.Context(),
`INSERT INTO yggdrasil_sessions (client_token, access_token, user_id, expires_at)
VALUES ($1, $2, $3, $4)`,
clientToken, accessToken, user.ID, expiresAt,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Internal Error", "Failed to create session")
return
}
resp := authenticateResponse{
AccessToken: accessToken,
ClientToken: clientToken,
SelectedProfile: &profile{
ID: user.UUID,
Name: user.Username,
},
AvailableProfile: []profile{{
ID: user.UUID,
Name: user.Username,
}},
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) refresh(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "Bad Request", "Cannot read body")
return
}
var req refreshRequest
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "Bad Request", "Invalid JSON")
return
}
// Look up session.
var userID int
var expiresAt time.Time
err = h.db.Pool().QueryRow(r.Context(),
`SELECT user_id, expires_at FROM yggdrasil_sessions
WHERE access_token = $1 AND client_token = $2`,
req.AccessToken, req.ClientToken,
).Scan(&userID, &expiresAt)
if err != nil {
writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid token")
return
}
if time.Now().After(expiresAt) {
writeError(w, http.StatusUnauthorized, "Forbidden", "Token expired")
return
}
// Rotate access token.
newAccessToken := generateToken()
_, err = h.db.Pool().Exec(r.Context(),
`UPDATE yggdrasil_sessions SET access_token = $1, expires_at = $2
WHERE access_token = $3`,
newAccessToken, time.Now().Add(24*time.Hour), req.AccessToken,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Internal Error", "Failed to refresh")
return
}
// Get user info.
var username, uuid string
err = h.db.Pool().QueryRow(r.Context(),
`SELECT username, uuid FROM users WHERE id = $1`, userID,
).Scan(&username, &uuid)
if err != nil {
writeError(w, http.StatusInternalServerError, "Internal Error", "User not found")
return
}
writeJSON(w, http.StatusOK, refreshResponse{
AccessToken: newAccessToken,
ClientToken: req.ClientToken,
SelectedProfile: &profile{
ID: uuid,
Name: username,
},
})
}
func (h *Handler) validate(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusNoContent)
return
}
var req refreshRequest
if err := json.Unmarshal(body, &req); err != nil {
w.WriteHeader(http.StatusNoContent)
return
}
var expiresAt time.Time
err = h.db.Pool().QueryRow(r.Context(),
`SELECT expires_at FROM yggdrasil_sessions
WHERE access_token = $1 AND client_token = $2`,
req.AccessToken, req.ClientToken,
).Scan(&expiresAt)
if err != nil || time.Now().After(expiresAt) {
w.WriteHeader(http.StatusNoContent)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── Helpers ───────────────────────────────────────────────────
func (h *Handler) findUser(ctx context.Context, login string) (*database.User, error) {
var user database.User
err := h.db.Pool().QueryRow(ctx,
`SELECT id, username, email, password_hash, uuid, role FROM users
WHERE username = $1 OR email = $1`, login,
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.UUID, &user.Role)
if err != nil {
return nil, err
}
return &user, nil
}
func verifyPassword(password, hash string) bool {
h := sha256.Sum256([]byte(password))
return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1
}
func generateToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, err, msg string) {
writeJSON(w, status, errorResponse{
Error: err,
ErrorMessage: msg,
})
}
// HashPassword returns the SHA-256 hex of a password for storage.
func HashPassword(password string) string {
h := sha256.Sum256([]byte(password))
return hex.EncodeToString(h[:])
}
// GenerateUUID creates a random UUID v4-like string.
func GenerateUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant
return fmt.Sprintf("%x-%x-%x-%x-%x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}

View File

@@ -1,2 +1,62 @@
// package config handles server configuration (env vars, config files).
// package config handles server configuration from environment variables.
package config
import (
"fmt"
"os"
"strconv"
)
// Config holds all server configuration.
type Config struct {
// HTTP
Port int `json:"port"`
// Database
DatabaseURL string `json:"database_url"`
// Storage
CASDir string `json:"cas_dir"` // Content-Addressable Storage root
SkinsDir string `json:"skins_dir"` // Uploaded skins
// Auth
JWTSecret string `json:"jwt_secret"`
CIsecret string `json:"ci_secret"` // Token for CI/CD launcher release endpoint
// Public
BaseURL string `json:"base_url"` // External URL for CDN links
}
// Load reads configuration from environment variables with sensible defaults.
func Load() (*Config, error) {
port, err := strconv.Atoi(getEnv("SERVER_PORT", "8080"))
if err != nil {
return nil, fmt.Errorf("invalid SERVER_PORT: %w", err)
}
cfg := &Config{
Port: port,
DatabaseURL: getEnv("DATABASE_URL", ""),
CASDir: getEnv("CAS_DIR", "/var/www/cdn/files"),
SkinsDir: getEnv("SKINS_DIR", "/var/www/cdn/skins"),
JWTSecret: getEnv("JWT_SECRET", ""),
CIsecret: getEnv("CI_SECRET", ""),
BaseURL: getEnv("BASE_URL", "https://minecraft.mrixs.me"),
}
if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
if cfg.JWTSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required")
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -1,2 +1,87 @@
// package database manages PostgreSQL connections, migrations, and data models.
// package database manages PostgreSQL connections and data models.
package database
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// DB wraps pgxpool.Pool with application-specific helpers.
type DB struct {
pool *pgxpool.Pool
}
// Open creates a new database connection pool.
func Open(ctx context.Context, databaseURL string) (*DB, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("connecting to database: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("pinging database: %w", err)
}
return &DB{pool: pool}, nil
}
// Close shuts down the connection pool.
func (db *DB) Close() {
db.pool.Close()
}
// Pool returns the underlying pool for direct queries.
func (db *DB) Pool() *pgxpool.Pool {
return db.pool
}
// ── Models ─────────────────────────────────────────────────────
// User represents a registered player.
type User struct {
ID int `db:"id"`
Username string `db:"username"`
Email string `db:"email"`
PasswordHash string `db:"password_hash"`
UUID string `db:"uuid"`
Role string `db:"role"`
}
// YggdrasilSession represents an active authentication session.
type YggdrasilSession struct {
ClientToken string `db:"client_token"`
AccessToken string `db:"access_token"`
UserID int `db:"user_id"`
ExpiresAt string `db:"expires_at"`
}
// Modpack represents a game server / modpack configuration.
type Modpack struct {
ID int `db:"id"`
Slug string `db:"slug"`
Name string `db:"name"`
MinecraftVersion string `db:"minecraft_version"`
JavaVersion int `db:"java_version"`
ServerIP string `db:"server_ip"`
IsActive bool `db:"is_active"`
}
// GlobalFile is a CAS entry.
type GlobalFile struct {
SHA1 string `db:"sha1"`
Size int64 `db:"size_bytes"`
FileName string `db:"file_name"`
}
// LauncherRelease tracks published launcher binaries.
type LauncherRelease struct {
ID int `db:"id"`
Version string `db:"version"`
OS string `db:"os"`
Arch string `db:"arch"`
SHA256 string `db:"sha256"`
FilePath string `db:"file_path"`
IsActive bool `db:"is_active"`
IsMandatory bool `db:"is_mandatory"`
}