feat: implement skins/capes, profile endpoints, session server
Skins & capes:
- Fix uploadSkin auth: Bearer token instead of user_id form hack
- Add POST /api/web/profile/cape (upload cape)
- Add DELETE /api/web/profile/skin and DELETE /api/web/profile/cape
- Validate skin PNG dimensions (64x32, 64x64, 128x128, 128x64)
- Add size limits: 1 MB for skins, 2 MB for capes
- Add basic email validation on register
Profile & session server:
- Add GET /api/web/profile/{uuid} — public profile with skin/cape hashes
- Add GET /sessionserver/session/minecraft/profile/{uuid} — Mojang-compatible
texture response for game client
- Add POST /authserver/invalidate and POST /authserver/signout
- Export VerifyPassword and ExtractBearer from auth package
- Remove duplicate verifyPassword from api.go
- Add PlayerTextures model to database.go
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth"
|
||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
||||||
)
|
)
|
||||||
@@ -51,7 +52,7 @@ const ctxKeyUserID ctxKey = 0
|
|||||||
|
|
||||||
func (h *Handler) auth(next http.HandlerFunc) http.HandlerFunc {
|
func (h *Handler) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
token := extractBearer(r.Header.Get("Authorization"))
|
token := auth.ExtractBearer(r.Header.Get("Authorization"))
|
||||||
if token == "" {
|
if token == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "Missing authorization token")
|
writeError(w, http.StatusUnauthorized, "Missing authorization token")
|
||||||
return
|
return
|
||||||
@@ -92,13 +93,6 @@ func (h *Handler) ciToken(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractBearer(h string) string {
|
|
||||||
if strings.HasPrefix(h, "Bearer ") {
|
|
||||||
return h[7:]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Modpack CRUD ──────────────────────────────────────────────
|
// ── Modpack CRUD ──────────────────────────────────────────────
|
||||||
|
|
||||||
type modpackRequest struct {
|
type modpackRequest struct {
|
||||||
|
|||||||
@@ -3,22 +3,37 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strings"
|
||||||
|
|
||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth"
|
||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Max skin file size: 1 MB (Minecraft skins are ~2-10 KB).
|
||||||
|
const maxSkinSize = 1 << 20
|
||||||
|
|
||||||
|
// Max cape file size: 2 MB.
|
||||||
|
const maxCapeSize = 2 << 20
|
||||||
|
|
||||||
|
// Valid Minecraft skin dimensions.
|
||||||
|
var skinDims = []struct {
|
||||||
|
W, H int
|
||||||
|
}{
|
||||||
|
{64, 32}, // legacy (pre-1.8)
|
||||||
|
{64, 64}, // modern
|
||||||
|
{128, 128},
|
||||||
|
{128, 64},
|
||||||
|
}
|
||||||
|
|
||||||
// Handler serves public API endpoints.
|
// Handler serves public API endpoints.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
@@ -36,12 +51,18 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /api/web/register", h.register)
|
mux.HandleFunc("POST /api/web/register", h.register)
|
||||||
mux.HandleFunc("POST /api/web/login", h.webLogin)
|
mux.HandleFunc("POST /api/web/login", h.webLogin)
|
||||||
mux.HandleFunc("POST /api/web/profile/skin", h.uploadSkin)
|
mux.HandleFunc("POST /api/web/profile/skin", h.uploadSkin)
|
||||||
|
mux.HandleFunc("POST /api/web/profile/cape", h.uploadCape)
|
||||||
|
mux.HandleFunc("DELETE /api/web/profile/skin", h.deleteSkin)
|
||||||
|
mux.HandleFunc("DELETE /api/web/profile/cape", h.deleteCape)
|
||||||
|
|
||||||
// Launcher endpoints.
|
// Launcher endpoints.
|
||||||
mux.HandleFunc("GET /api/launcher/latest", h.launcherLatest)
|
mux.HandleFunc("GET /api/launcher/latest", h.launcherLatest)
|
||||||
mux.HandleFunc("GET /api/servers.json", h.serversList)
|
mux.HandleFunc("GET /api/servers.json", h.serversList)
|
||||||
mux.HandleFunc("GET /api/instances/{slug}/manifest.json", h.instanceManifest)
|
mux.HandleFunc("GET /api/instances/{slug}/manifest.json", h.instanceManifest)
|
||||||
|
|
||||||
|
// Profile — public read.
|
||||||
|
mux.HandleFunc("GET /api/web/profile/{uuid}", h.getProfile)
|
||||||
|
|
||||||
// Skin serving.
|
// Skin serving.
|
||||||
mux.HandleFunc("GET /skins/{hash}.png", h.serveSkin)
|
mux.HandleFunc("GET /skins/{hash}.png", h.serveSkin)
|
||||||
}
|
}
|
||||||
@@ -87,6 +108,31 @@ type launcherLatestResponse struct {
|
|||||||
IsMandatory bool `json:"is_mandatory"`
|
IsMandatory bool `json:"is_mandatory"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type profileResponse struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Textures *textureInfo `json:"textures,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type textureInfo struct {
|
||||||
|
SkinHash string `json:"skin_hash,omitempty"`
|
||||||
|
CapeHash string `json:"cape_hash,omitempty"`
|
||||||
|
IsSlim bool `json:"is_slim"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mojang session server profile response (for game client).
|
||||||
|
type sessionProfileResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Props []sessionProfileProp `json:"properties"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionProfileProp struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Signature string `json:"signature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type errorResponse struct {
|
type errorResponse struct {
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
}
|
}
|
||||||
@@ -111,6 +157,12 @@ func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Basic email validation.
|
||||||
|
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid email address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check uniqueness.
|
// Check uniqueness.
|
||||||
var exists int
|
var exists int
|
||||||
err = h.db.Pool().QueryRow(r.Context(),
|
err = h.db.Pool().QueryRow(r.Context(),
|
||||||
@@ -162,7 +214,7 @@ func (h *Handler) webLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Username,
|
req.Username,
|
||||||
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.UUID)
|
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.UUID)
|
||||||
|
|
||||||
if err != nil || !verifyPassword(req.Password, user.PasswordHash) {
|
if err != nil || !auth.VerifyPassword(req.Password, user.PasswordHash) {
|
||||||
writeError(w, http.StatusUnauthorized, "Invalid credentials")
|
writeError(w, http.StatusUnauthorized, "Invalid credentials")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -188,11 +240,14 @@ func (h *Handler) webLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: authenticate via session token from header.
|
userID := h.authenticateRequest(w, r)
|
||||||
// For now, accept user_id from form (to be replaced with proper auth).
|
if userID == 0 {
|
||||||
userID, err := strconv.Atoi(r.FormValue("user_id"))
|
return
|
||||||
if err != nil || userID == 0 {
|
}
|
||||||
writeError(w, http.StatusUnauthorized, "Authentication required")
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxSkinSize)
|
||||||
|
if err := r.ParseMultipartForm(maxSkinSize); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Cannot parse form (max 1 MB)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,13 +264,11 @@ func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate PNG.
|
if !isValidSkinPNG(data) {
|
||||||
if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" {
|
writeError(w, http.StatusBadRequest, "Invalid skin: must be a valid Minecraft skin PNG (64x32, 64x64, 128x128, or 128x64)")
|
||||||
writeError(w, http.StatusBadRequest, "Invalid PNG file")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute SHA-1 hash.
|
|
||||||
hash := sha1Hex(data)
|
hash := sha1Hex(data)
|
||||||
|
|
||||||
// Store in CAS.
|
// Store in CAS.
|
||||||
@@ -245,6 +298,124 @@ func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"hash": hash})
|
writeJSON(w, http.StatusOK, map[string]string{"hash": hash})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) uploadCape(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := h.authenticateRequest(w, r)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxCapeSize)
|
||||||
|
if err := r.ParseMultipartForm(maxCapeSize); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Cannot parse form (max 2 MB)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, _, err := r.FormFile("cape")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "No cape file provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Cannot read cape file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate PNG header.
|
||||||
|
if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid PNG file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := sha1Hex(data)
|
||||||
|
|
||||||
|
// Store in skins dir alongside skins (same CAS layout).
|
||||||
|
destDir := filepath.Join(h.cfg.SkinsDir, hash[:2])
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Failed to store cape")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dest := filepath.Join(destDir, hash+".png")
|
||||||
|
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Failed to write cape")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user profile.
|
||||||
|
_, err = h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO player_textures (user_id, cape_hash)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET cape_hash = $2`,
|
||||||
|
userID, hash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Failed to update profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"hash": hash})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteSkin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := h.authenticateRequest(w, r)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.db.Pool().Exec(r.Context(),
|
||||||
|
`UPDATE player_textures SET skin_hash = NULL WHERE user_id = $1`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Failed to remove skin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "skin removed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteCape(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := h.authenticateRequest(w, r)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.db.Pool().Exec(r.Context(),
|
||||||
|
`UPDATE player_textures SET cape_hash = NULL WHERE user_id = $1`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Failed to remove cape")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "cape removed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticateRequest extracts and validates the Bearer token from the request,
|
||||||
|
// returning the user ID on success or 0 on failure (having already written the error).
|
||||||
|
func (h *Handler) authenticateRequest(w http.ResponseWriter, r *http.Request) int {
|
||||||
|
token := auth.ExtractBearer(r.Header.Get("Authorization"))
|
||||||
|
if token == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Missing authorization token")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID int
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT user_id FROM yggdrasil_sessions
|
||||||
|
WHERE access_token = $1 AND expires_at > NOW()`,
|
||||||
|
token,
|
||||||
|
).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Invalid or expired token")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) launcherLatest(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) launcherLatest(w http.ResponseWriter, r *http.Request) {
|
||||||
osParam := r.URL.Query().Get("os")
|
osParam := r.URL.Query().Get("os")
|
||||||
archParam := r.URL.Query().Get("arch")
|
archParam := r.URL.Query().Get("arch")
|
||||||
@@ -330,6 +501,39 @@ func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write(data)
|
w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uuid := r.PathValue("uuid")
|
||||||
|
if uuid == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "UUID is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var p profileResponse
|
||||||
|
var skinHash, capeHash *string
|
||||||
|
var isSlim bool
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT u.uuid, u.username, pt.skin_hash, pt.cape_hash, COALESCE(pt.is_slim, false)
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN player_textures pt ON pt.user_id = u.id
|
||||||
|
WHERE u.uuid = $1`,
|
||||||
|
uuid,
|
||||||
|
).Scan(&p.UUID, &p.Username, &skinHash, &capeHash, &isSlim)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Player not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if skinHash != nil || capeHash != nil {
|
||||||
|
p.Textures = &textureInfo{
|
||||||
|
SkinHash: deref(skinHash),
|
||||||
|
CapeHash: deref(capeHash),
|
||||||
|
IsSlim: isSlim,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) serveSkin(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) serveSkin(w http.ResponseWriter, r *http.Request) {
|
||||||
hash := r.PathValue("hash")
|
hash := r.PathValue("hash")
|
||||||
if hash == "" {
|
if hash == "" {
|
||||||
@@ -359,16 +563,37 @@ func (h *Handler) serveSkin(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
func verifyPassword(password, hash string) bool {
|
|
||||||
h := sha256.Sum256([]byte(password))
|
|
||||||
return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func sha1Hex(data []byte) string {
|
func sha1Hex(data []byte) string {
|
||||||
h := sha1.Sum(data)
|
h := sha1.Sum(data)
|
||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isValidSkinPNG checks that data is a valid PNG with accepted Minecraft skin dimensions.
|
||||||
|
func isValidSkinPNG(data []byte) bool {
|
||||||
|
if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := png.DecodeConfig(strings.NewReader(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range skinDims {
|
||||||
|
if cfg.Width == d.W && cfg.Height == d.H {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
||||||
@@ -33,6 +34,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /authserver/authenticate", h.authenticate)
|
mux.HandleFunc("POST /authserver/authenticate", h.authenticate)
|
||||||
mux.HandleFunc("POST /authserver/refresh", h.refresh)
|
mux.HandleFunc("POST /authserver/refresh", h.refresh)
|
||||||
mux.HandleFunc("POST /authserver/validate", h.validate)
|
mux.HandleFunc("POST /authserver/validate", h.validate)
|
||||||
|
mux.HandleFunc("POST /authserver/invalidate", h.invalidate)
|
||||||
|
mux.HandleFunc("POST /authserver/signout", h.signout)
|
||||||
|
|
||||||
|
// Session server — game client queries player skins/profile.
|
||||||
|
mux.HandleFunc("GET /sessionserver/session/minecraft/profile/{uuid}", h.sessionProfile)
|
||||||
|
mux.HandleFunc("GET /sessionserver/session/minecraft/profile/{unsigned}", h.sessionProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Request / Response types ──────────────────────────────────
|
// ── Request / Response types ──────────────────────────────────
|
||||||
@@ -81,6 +88,19 @@ type errorResponse struct {
|
|||||||
ErrorMessage string `json:"errorMessage"`
|
ErrorMessage string `json:"errorMessage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session server types (Mojang-compatible).
|
||||||
|
type sessionProfileResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Props []sessionProfileProp `json:"properties"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionProfileProp struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Signature string `json:"signature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ── Handlers ──────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -104,7 +124,7 @@ func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify password (SHA-256 hex comparison).
|
// Verify password (SHA-256 hex comparison).
|
||||||
if !verifyPassword(req.Password, user.PasswordHash) {
|
if !VerifyPassword(req.Password, user.PasswordHash) {
|
||||||
writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid credentials")
|
writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid credentials")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -233,6 +253,111 @@ func (h *Handler) validate(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) invalidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req refreshRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = h.db.Pool().Exec(r.Context(),
|
||||||
|
`DELETE FROM yggdrasil_sessions WHERE access_token = $1 AND client_token = $2`,
|
||||||
|
req.AccessToken, req.ClientToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) signout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.findUser(r.Context(), req.Username)
|
||||||
|
if err != nil || !VerifyPassword(req.Password, user.PasswordHash) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = h.db.Pool().Exec(r.Context(),
|
||||||
|
`DELETE FROM yggdrasil_sessions WHERE user_id = $1`, user.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionProfile returns the Mojang-compatible profile for the game client.
|
||||||
|
// URL: GET /sessionserver/session/minecraft/profile/{uuid}
|
||||||
|
// The game client uses this to look up player textures (skin + cape).
|
||||||
|
func (h *Handler) sessionProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uuid := r.PathValue("uuid")
|
||||||
|
if uuid == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up user + textures by UUID.
|
||||||
|
var username string
|
||||||
|
var skinHash, capeHash *string
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT u.username, pt.skin_hash, pt.cape_hash
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN player_textures pt ON pt.user_id = u.id
|
||||||
|
WHERE u.uuid = $1`,
|
||||||
|
uuid,
|
||||||
|
).Scan(&username, &skinHash, &capeHash)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build texture URL prefix.
|
||||||
|
textureBase := h.cfg.BaseURL + "/skins/"
|
||||||
|
|
||||||
|
// Build properties (Mojang format).
|
||||||
|
props := make([]sessionProfileProp, 0, 1)
|
||||||
|
texObj := make(map[string]string)
|
||||||
|
if skinHash != nil && *skinHash != "" {
|
||||||
|
texObj["skin"] = textureBase + *skinHash + ".png"
|
||||||
|
}
|
||||||
|
if capeHash != nil && *capeHash != "" {
|
||||||
|
texObj["cape"] = textureBase + *capeHash + ".png"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(texObj) > 0 {
|
||||||
|
texJSON, _ := json.Marshal(texObj)
|
||||||
|
props = append(props, sessionProfileProp{
|
||||||
|
Name: "textures",
|
||||||
|
Value: string(texJSON),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
prof := sessionProfileResponse{
|
||||||
|
ID: uuid,
|
||||||
|
Name: username,
|
||||||
|
Props: props,
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, prof)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) findUser(ctx context.Context, login string) (*database.User, error) {
|
func (h *Handler) findUser(ctx context.Context, login string) (*database.User, error) {
|
||||||
@@ -247,7 +372,16 @@ func (h *Handler) findUser(ctx context.Context, login string) (*database.User, e
|
|||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyPassword(password, hash string) bool {
|
// ExtractBearer extracts the token from "Authorization: Bearer <token>" header.
|
||||||
|
func ExtractBearer(h string) string {
|
||||||
|
if strings.HasPrefix(h, "Bearer ") {
|
||||||
|
return h[7:]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword checks a plaintext password against a SHA-256 hex hash.
|
||||||
|
func VerifyPassword(password, hash string) bool {
|
||||||
h := sha256.Sum256([]byte(password))
|
h := sha256.Sum256([]byte(password))
|
||||||
return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1
|
return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ type GlobalFile struct {
|
|||||||
FileName string `db:"file_name"`
|
FileName string `db:"file_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlayerTextures holds skin/cape references for a user.
|
||||||
|
type PlayerTextures struct {
|
||||||
|
UserID int `db:"user_id"`
|
||||||
|
SkinHash string `db:"skin_hash"`
|
||||||
|
CapeHash string `db:"cape_hash"`
|
||||||
|
IsSlim bool `db:"is_slim"`
|
||||||
|
}
|
||||||
|
|
||||||
// LauncherRelease tracks published launcher binaries.
|
// LauncherRelease tracks published launcher binaries.
|
||||||
type LauncherRelease struct {
|
type LauncherRelease struct {
|
||||||
ID int `db:"id"`
|
ID int `db:"id"`
|
||||||
|
|||||||
Reference in New Issue
Block a user