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:
2026-05-27 11:45:33 +03:00
parent e4fea937aa
commit 01cce981c5
4 changed files with 397 additions and 36 deletions

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"
"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/refresh", h.refresh)
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 ──────────────────────────────────
@@ -81,6 +88,19 @@ type errorResponse struct {
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 ──────────────────────────────────────────────────
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).
if !verifyPassword(req.Password, user.PasswordHash) {
if !VerifyPassword(req.Password, user.PasswordHash) {
writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid credentials")
return
}
@@ -233,6 +253,111 @@ func (h *Handler) validate(w http.ResponseWriter, r *http.Request) {
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 ───────────────────────────────────────────────────
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
}
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))
return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1
}