Files
MrixsCraft-server/internal/auth/auth.go
Vladimir Zagainov 4efcc770ac
All checks were successful
CI / lint (push) Successful in 9m54s
CI / test (push) Successful in 10m19s
CI / build (push) Successful in 9m58s
CI / docker (push) Has been skipped
fix: format all Go files with gofmt
- Fix alignment in struct fields (sessionProfileResponse, textureInfo, Handler)
- Align struct field values in internal/templates/templates.go, internal/api/api.go
2026-05-30 20:00:54 +03:00

432 lines
12 KiB
Go

// 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 <token>" 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])
}