// package auth implements the Yggdrasil authentication protocol. package auth import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "golang.org/x/crypto/bcrypt" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" "gitea.mrixs.me/Mrixs/MrixsCraft-server/pkg/utils" ) // 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) 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 ────────────────────────────────── 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"` } // 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) { 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, }}, } utils.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 } utils.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) } 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, } utils.WriteJSON(w, http.StatusOK, prof) } // ── 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 } // ExtractBearer extracts the token from "Authorization: Bearer " header. func ExtractBearer(h string) string { if strings.HasPrefix(h, "Bearer ") { return h[7:] } return "" } // VerifyPassword checks a plaintext password against a stored bcrypt hash. func VerifyPassword(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } // GenerateToken creates a random hex token (16 bytes → 32 hex chars). func GenerateToken() string { b := make([]byte, 16) _, _ = rand.Read(b) return hex.EncodeToString(b) } func writeError(w http.ResponseWriter, status int, err, msg string) { utils.WriteJSON(w, status, errorResponse{ Error: err, ErrorMessage: msg, }) } // HashPassword returns a bcrypt hash of the password for storage. func HashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", fmt.Errorf("hashing password: %w", err) } return string(hash), nil } // IsBcryptHash reports whether the given hash looks like a bcrypt hash // (starts with $2a$, $2b$, or $2y$). Used to detect legacy SHA-256 hashes. func IsBcryptHash(hash string) bool { return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") } // ErrPasswordHashing is returned when bcrypt hashing fails. var ErrPasswordHashing = errors.New("password hashing failed") // 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]) }