Files
scrabble-game/backend/internal/server/handlers_auth.go
T
Ilia Denisov 408da3f201
Tests · Go / test (push) Successful in 8s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 10s
Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
New public ingress and the first network edge. Framework + a vertical slice of
operations end-to-end; remaining ops reuse the same transcode pattern in Stage 7.

Contracts (new module scrabble/pkg):
- push.proto (backend->gateway gRPC server-stream) + scrabble.fbs (FlatBuffers
  edge payloads), committed generated Go; buf/flatc Makefiles (dev-time codegen).

Backend:
- REST handlers on the /api/v1 groups: internal session endpoints
  (telegram/guest/email login -> mint, resolve, revoke) and the user slice
  (profile, submit_play, state, lobby enqueue/poll, chat).
- internal/notify in-process Publisher hub + internal/pushgrpc gRPC server
  (BACKEND_GRPC_ADDR) streaming your_turn/opponent_moved/chat/nudge/match_found;
  emission in game.commit, social, matchmaker.
- migration 00005 accounts.is_guest; guests are durable rows excluded from stats;
  ProvisionGuest; email-as-login (RequestLoginCode/LoginWithCode).

Gateway (new module scrabble/gateway):
- Connect Gateway service over h2c (Execute + Subscribe), FlatBuffers<->JSON
  transcode registry, Telegram initData HMAC validator (seam), session cache,
  token-bucket rate limiter (3 classes), push fan-out hub, backend REST + push
  gRPC client, admin Basic-Auth reverse proxy.

go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/**
path filters; unit build/vet/test span all three modules. Docs (PLAN,
ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests +
guest/email-login integration tests.
2026-06-02 22:38:24 +02:00

135 lines
4.0 KiB
Go

package server
import (
"net/http"
"github.com/gin-gonic/gin"
"scrabble/backend/internal/account"
)
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
// already validated the originating credential (Telegram initData, an email
// code, or a guest bootstrap) and forwards the result here to provision the
// account and mint the opaque session. The backend trusts the gateway on this
// segment (docs/ARCHITECTURE.md §12).
// telegramAuthRequest carries the platform user id the gateway extracted from a
// validated initData payload.
type telegramAuthRequest struct {
ExternalID string `json:"external_id"`
}
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
// identity and mints a session for it.
func (s *Server) handleTelegramAuth(c *gin.Context) {
var req telegramAuthRequest
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "external_id is required")
return
}
acc, err := s.accounts.ProvisionByIdentity(c.Request.Context(), account.KindTelegram, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
}
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session.
func (s *Server) handleGuestAuth(c *gin.Context) {
acc, err := s.accounts.ProvisionGuest(c.Request.Context())
if err != nil {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
}
// emailRequest is an email-login code request.
type emailRequest struct {
Email string `json:"email"`
}
// handleEmailRequest issues a login confirm-code to the email. It always reports
// success once the address is well-formed, so the response does not reveal
// whether an account already exists.
func (s *Server) handleEmailRequest(c *gin.Context) {
var req emailRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Email == "" {
abortBadRequest(c, "email is required")
return
}
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// emailLoginRequest verifies an email login code.
type emailLoginRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
// handleEmailLogin verifies the code and mints a session for the owning account.
func (s *Server) handleEmailLogin(c *gin.Context) {
var req emailLoginRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Email == "" || req.Code == "" {
abortBadRequest(c, "email and code are required")
return
}
acc, err := s.emails.LoginWithCode(c.Request.Context(), req.Email, req.Code)
if err != nil {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
}
// tokenRequest carries an opaque session token.
type tokenRequest struct {
Token string `json:"token"`
}
// handleResolveSession resolves a token to its account id. The gateway calls it
// on a session-cache miss.
func (s *Server) handleResolveSession(c *gin.Context) {
var req tokenRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
abortBadRequest(c, "token is required")
return
}
sess, err := s.sessions.Resolve(c.Request.Context(), req.Token)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, resolveResponse{UserID: sess.AccountID.String()})
}
// handleRevokeSession revokes the session for a token (idempotent).
func (s *Server) handleRevokeSession(c *gin.Context) {
var req tokenRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
abortBadRequest(c, "token is required")
return
}
if err := s.sessions.Revoke(c.Request.Context(), req.Token); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// mintSession creates a session for acc and writes the credential response.
func (s *Server) mintSession(c *gin.Context, acc account.Account) {
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, sessionResponseFor(token, acc))
}