Manage Yggdrasil providers from the panel using durable platform settings, protected identity namespaces and atomic revisions. Apply changes to subsequent logins and profile lookups without restarting. Return operator-host logouts to the login method selection page.
201 lines
6.6 KiB
Go
201 lines
6.6 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type linkedProfile struct {
|
|
Source string `json:"source"`
|
|
Name string `json:"name"`
|
|
ProfileUUID string `json:"profile_uuid"`
|
|
MCUUID string `json:"mc_uuid"`
|
|
AuthSource string `json:"auth_source"`
|
|
}
|
|
|
|
func profileAPIBase(src AuthSource) string {
|
|
if src.APIURL != "" {
|
|
return strings.TrimRight(src.APIURL, "/")
|
|
}
|
|
const suffix = "/sessionserver/session/minecraft/hasJoined"
|
|
if strings.HasSuffix(src.URL, suffix) {
|
|
return strings.TrimSuffix(src.URL, suffix)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (a *API) handleLinkSources(w http.ResponseWriter, r *http.Request) {
|
|
type sourceView struct {
|
|
Tag string `json:"tag"`
|
|
LookupAvailable bool `json:"lookup_available"`
|
|
}
|
|
configured, err := a.currentAuthSources(r.Context())
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
sources := make([]sourceView, 0, len(configured))
|
|
for _, src := range configured {
|
|
sources = append(sources, sourceView{src.Tag, src.Identity || profileAPIBase(src) != ""})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"sources": sources})
|
|
}
|
|
|
|
func (a *API) handleLookupProfile(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
profile, err := a.lookupProfile(r.Context(), q.Get("source"), q.Get("profile"))
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, profile)
|
|
}
|
|
|
|
// handleLinkProfile is a staff designation, not proof of game-account ownership.
|
|
// The user must already have panel authority and a fresh login factor. A client
|
|
// supplies only the selected source and native role UUID; mapping and target user
|
|
// are determined on the server.
|
|
func (a *API) handleLinkProfile(w http.ResponseWriter, r *http.Request) {
|
|
p := principalFromContext(r.Context())
|
|
if !a.requireReauth(w, r, p) {
|
|
return
|
|
}
|
|
if err := requireJSONContentType(r); err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
var req struct {
|
|
Source string `json:"source"`
|
|
ProfileUUID string `json:"profile_uuid"`
|
|
}
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
if _, err := uuid.Parse(req.ProfileUUID); err != nil {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "profile_uuid must be a role UUID"))
|
|
return
|
|
}
|
|
profile, err := a.lookupProfile(r.Context(), req.Source, req.ProfileUUID)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
err = a.Repo.LinkAccount(r.Context(), p.UserID, profile.MCUUID, profile.AuthSource)
|
|
if errors.Is(err, ErrConflict) {
|
|
writeError(w, r, newError(http.StatusConflict, "already_linked", "that Minecraft role is linked to another user"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
a.audit(r, "account.link_profile", "")
|
|
writeJSON(w, http.StatusOK, map[string]any{"linked": true, "mc_uuid": profile.MCUUID, "auth_source": profile.AuthSource})
|
|
}
|
|
|
|
func (a *API) lookupProfile(ctx context.Context, source, input string) (*linkedProfile, error) {
|
|
input = strings.TrimSpace(input)
|
|
id, idErr := uuid.Parse(input)
|
|
if idErr != nil && !mcUsernameRe.MatchString(input) {
|
|
return nil, newError(http.StatusBadRequest, "bad_request", "provide a Minecraft role name or UUID")
|
|
}
|
|
var src AuthSource
|
|
found := false
|
|
sources, err := a.currentAuthSources(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, candidate := range sources {
|
|
if candidate.Tag == source {
|
|
src, found = candidate, true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, newError(http.StatusBadRequest, "auth_source_unknown", "select a configured authentication source")
|
|
}
|
|
var target, method string
|
|
var body io.Reader
|
|
if src.Identity {
|
|
method = http.MethodGet
|
|
if idErr == nil {
|
|
target = strings.TrimSuffix(src.URL, "/hasJoined") + "/profile/" + strings.ReplaceAll(id.String(), "-", "")
|
|
} else {
|
|
target = mojangProfileAPI + url.PathEscape(input)
|
|
}
|
|
} else {
|
|
base := profileAPIBase(src)
|
|
if base == "" {
|
|
return nil, newError(http.StatusBadRequest, "auth_source_lookup_unsupported", "this source needs api_url for role lookup; game-code linking is still available")
|
|
}
|
|
if idErr == nil {
|
|
method, target = http.MethodGet, base+"/sessionserver/session/minecraft/profile/"+strings.ReplaceAll(id.String(), "-", "")
|
|
} else {
|
|
method, target = http.MethodPost, base+"/api/profiles/minecraft"
|
|
encoded, _ := json.Marshal([]string{input})
|
|
body = bytes.NewReader(encoded)
|
|
}
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, target, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
// Use the same bounded, redirect-free client as game authentication.
|
|
resp, err := authHTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the selected authentication source is unavailable")
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotFound {
|
|
return nil, newError(http.StatusNotFound, "minecraft_profile_not_found", "no role matched in the selected source")
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the selected authentication source returned HTTP %d", resp.StatusCode)
|
|
}
|
|
decoder := json.NewDecoder(io.LimitReader(resp.Body, 1<<16))
|
|
var profile sessionProfile
|
|
if method == http.MethodPost {
|
|
var profiles []sessionProfile
|
|
if err := decoder.Decode(&profiles); err != nil {
|
|
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "invalid profile response")
|
|
}
|
|
for _, candidate := range profiles {
|
|
if strings.EqualFold(candidate.Name, input) {
|
|
profile = candidate
|
|
break
|
|
}
|
|
}
|
|
if profile.ID == "" {
|
|
return nil, newError(http.StatusNotFound, "minecraft_profile_not_found", "no role matched in the selected source")
|
|
}
|
|
} else if err := decoder.Decode(&profile); err != nil {
|
|
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "invalid profile response")
|
|
}
|
|
profileID, err := uuid.Parse(profile.ID)
|
|
if err != nil || !mcUsernameRe.MatchString(profile.Name) ||
|
|
(idErr == nil && profileID != id) || (idErr != nil && !strings.EqualFold(profile.Name, input)) {
|
|
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "the source returned a mismatched or invalid role")
|
|
}
|
|
canonical, err := canonicalProfileUUID(src, profile.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
authSource := authSourceThirdParty
|
|
if src.Identity {
|
|
authSource = authSourceMojang
|
|
}
|
|
return &linkedProfile{Source: src.Tag, Name: profile.Name, ProfileUUID: profile.ID, MCUUID: canonical.String(), AuthSource: authSource}, nil
|
|
}
|