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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user