25d80bc31d
Add POST /internal/sessions/email/confirm-link: it verifies a deeplink token via ConfirmByToken and, for a login, mints a session (the deeplink page signs in with it); for a link, attaches the confirmed email and reports "confirmed" or "merge_required" (the app drives the interactive merge). The token, not a request session, is the authorization. Add LinkConfirmation.IsLogin() and integration tests for the login, link and merge branches (the token is read from the mailed link). The gateway RPC, live event and SPA route follow.
313 lines
12 KiB
Go
313 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"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 identity the connector extracted from a
|
|
// validated initData payload. Username, FirstName and LanguageCode seed a
|
|
// brand-new account's display name and language; BrowserTZ (the client's detected
|
|
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
|
|
// deep-link payload, which may seed the new account's variant preferences (first
|
|
// contact only).
|
|
type telegramAuthRequest struct {
|
|
ExternalID string `json:"external_id"`
|
|
Username string `json:"username"`
|
|
FirstName string `json:"first_name"`
|
|
LanguageCode string `json:"language_code"`
|
|
BrowserTZ string `json:"browser_tz"`
|
|
StartParam string `json:"start_param"`
|
|
}
|
|
|
|
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
|
// identity and mints a session for it, seeding a new account's display name and
|
|
// language from the supplied Telegram fields (first contact only).
|
|
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, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName, req.BrowserTZ)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
if created {
|
|
// First registration: re-evaluate moderated-chat write access, so a user who
|
|
// joined the chat before registering is granted on the spot (no chat_member
|
|
// event fires on registration).
|
|
s.publishChatAccessChange(acc.ID)
|
|
// A promo deep-link may seed this brand-new account's variant preferences (e.g.
|
|
// English Scrabble alongside the default Erudit). Best-effort: an absent or
|
|
// malformed payload leaves the account on its defaults, and a write failure must
|
|
// not block the session mint.
|
|
if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 {
|
|
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil {
|
|
s.log.Warn("telegram: seed variant preferences failed",
|
|
zap.String("account", acc.ID.String()), zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
s.mintSession(c, acc)
|
|
}
|
|
|
|
// vkAuthRequest carries the identity the gateway extracted from verified VK launch
|
|
// params. LanguageCode (vk_language) and DisplayName (read client-side via
|
|
// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new
|
|
// account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC
|
|
// offset) seeds its time zone. All seeds apply on first contact only.
|
|
type vkAuthRequest struct {
|
|
ExternalID string `json:"external_id"`
|
|
LanguageCode string `json:"language_code"`
|
|
DisplayName string `json:"display_name"`
|
|
BrowserTZ string `json:"browser_tz"`
|
|
}
|
|
|
|
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
|
|
// session for it, seeding a new account's language and display name from the supplied VK
|
|
// fields (first contact only). Unlike Telegram there is no moderated-chat re-evaluation
|
|
// or deep-link variant seed: a fresh VK account has no Telegram chat eligibility and the
|
|
// MVP carries no launch deep link.
|
|
func (s *Server) handleVKAuth(c *gin.Context) {
|
|
var req vkAuthRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
|
abortBadRequest(c, "external_id is required")
|
|
return
|
|
}
|
|
acc, _, err := s.accounts.ProvisionVK(c.Request.Context(), req.ExternalID, req.LanguageCode, req.DisplayName, req.BrowserTZ)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.mintSession(c, acc)
|
|
}
|
|
|
|
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
|
type pushTargetRequest struct {
|
|
UserID string `json:"user_id"`
|
|
}
|
|
|
|
// pushTargetResponse carries what the gateway needs to route an out-of-app push:
|
|
// the recipient's Telegram external_id (empty when they have no Telegram
|
|
// identity, e.g. a guest or email-only account), the language the single bot renders
|
|
// the message in (the account's interface language), and whether they confined
|
|
// notifications to the in-app stream.
|
|
type pushTargetResponse struct {
|
|
ExternalID string `json:"external_id"`
|
|
Language string `json:"language"`
|
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
|
}
|
|
|
|
// handlePushTarget resolves a user id to the data the gateway needs to deliver an
|
|
// out-of-app Telegram notification — the gateway-only internal counterpart of the
|
|
// in-app push stream. A user with no Telegram identity yields an empty external_id,
|
|
// which the gateway treats as "no out-of-app channel".
|
|
func (s *Server) handlePushTarget(c *gin.Context) {
|
|
var req pushTargetRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.UserID == "" {
|
|
abortBadRequest(c, "user_id is required")
|
|
return
|
|
}
|
|
uid, err := uuid.Parse(req.UserID)
|
|
if err != nil {
|
|
abortBadRequest(c, "user_id must be a uuid")
|
|
return
|
|
}
|
|
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
ext, err := s.accounts.IdentityExternalID(c.Request.Context(), uid, account.KindTelegram)
|
|
if err != nil && !errors.Is(err, account.ErrNotFound) {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, pushTargetResponse{
|
|
ExternalID: ext,
|
|
Language: acc.PreferredLanguage,
|
|
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
|
})
|
|
}
|
|
|
|
// guestAuthRequest carries the guest bootstrap's optional time-zone seed: BrowserTZ
|
|
// (the client's detected "±HH:MM" UTC offset) is written to the new guest account's
|
|
// time zone, so robot timing is anchored to the player's zone from the first game.
|
|
type guestAuthRequest struct {
|
|
BrowserTZ string `json:"browser_tz"`
|
|
}
|
|
|
|
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
|
|
// seeding its time zone from the optional detected browser offset.
|
|
func (s *Server) handleGuestAuth(c *gin.Context) {
|
|
// The body is optional: an absent or malformed one simply yields no time-zone seed
|
|
// (the account keeps the UTC default), so a bind error must not fail the bootstrap.
|
|
var req guestAuthRequest
|
|
_ = c.ShouldBindJSON(&req)
|
|
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.mintSession(c, acc)
|
|
}
|
|
|
|
// emailRequest is an email-login code request. BrowserTZ (the client's detected
|
|
// "±HH:MM" UTC offset) seeds the time zone of an account provisioned here on first
|
|
// contact (the email account is created at the request step, not at login).
|
|
type emailRequest struct {
|
|
Email string `json:"email"`
|
|
BrowserTZ string `json:"browser_tz"`
|
|
Language string `json:"language"`
|
|
}
|
|
|
|
// 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, req.BrowserTZ, req.Language); 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)
|
|
}
|
|
|
|
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
|
|
// Session carries the freshly minted credential (the deeplink page signs in with it);
|
|
// for a link, Status is "confirmed" or "merge_required" (the app completes the merge).
|
|
type confirmLinkResponse struct {
|
|
Purpose string `json:"purpose"`
|
|
Status string `json:"status"`
|
|
Session *sessionResponse `json:"session,omitempty"`
|
|
}
|
|
|
|
// handleEmailConfirmLink verifies a one-tap deeplink token. A login mints a session
|
|
// (the deeplink page signs in with it); a link attaches the confirmed email, reporting
|
|
// "merge_required" when the address is owned by another account so the app drives the
|
|
// interactive merge. The token, not a request session, is the authorization — the page
|
|
// need not be signed in.
|
|
func (s *Server) handleEmailConfirmLink(c *gin.Context) {
|
|
var req tokenRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
|
|
abortBadRequest(c, "token is required")
|
|
return
|
|
}
|
|
res, err := s.emails.ConfirmByToken(c.Request.Context(), req.Token)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
if res.IsLogin() {
|
|
acc, err := s.accounts.GetByID(c.Request.Context(), res.Account)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
sess := sessionResponseFor(token, acc)
|
|
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "login", Status: "confirmed", Session: &sess})
|
|
return
|
|
}
|
|
status := "confirmed"
|
|
if res.NeedsMerge {
|
|
status = "merge_required"
|
|
}
|
|
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: status})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// is_guest is best-effort: a transient account read must not fail an otherwise
|
|
// valid resolve (the auth hot path), so a read error falls back to false; the
|
|
// per-operation backend gate remains the authoritative guest check.
|
|
resp := resolveResponse{UserID: sess.AccountID.String()}
|
|
if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil {
|
|
resp.IsGuest = acc.IsGuest
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// 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))
|
|
}
|