Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9
This commit is contained in:
commit
b443ff0c73
277 changed files with 30747 additions and 1551 deletions
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -33,6 +34,10 @@ const (
|
|||
botFatherCmdSetInlineFB = "setinlinefeedback"
|
||||
botFatherCmdSetJoinGroups = "setjoingroups"
|
||||
botFatherCmdSetPrivacy = "setprivacy"
|
||||
botFatherCmdSetLogin = "setlogin"
|
||||
botFatherCmdLoginInfo = "logininfo"
|
||||
botFatherCmdResetLogin = "resetloginsecret"
|
||||
botFatherCmdDone = "done"
|
||||
|
||||
botFatherStepName = "name"
|
||||
botFatherStepUsername = "username"
|
||||
|
|
@ -41,6 +46,8 @@ const (
|
|||
|
||||
botFatherDraftBotID = "bot_id"
|
||||
botFatherDraftBotUsername = "bot_username"
|
||||
|
||||
maxTelegramLoginCommandsPerMessage = 32
|
||||
)
|
||||
|
||||
const botFatherHelpText = `I can help you create and manage ` + branding.ProductName + ` bots.
|
||||
|
|
@ -60,6 +67,10 @@ You can control me by sending these commands:
|
|||
/setinlinefeedback - change inline feedback settings
|
||||
/setjoingroups - toggle whether a bot can join groups
|
||||
/setprivacy - toggle a bot's group privacy mode
|
||||
/setlogin - configure Telegram Login allowed URLs and signing
|
||||
/logininfo - show a bot's Telegram Login configuration
|
||||
/resetloginsecret - rotate a bot's OIDC Client Secret
|
||||
/done - finish the active Telegram Login configuration
|
||||
/cancel - cancel the current operation
|
||||
/help - show this message`
|
||||
|
||||
|
|
@ -169,12 +180,13 @@ func (s *Service) botReplyRandomID() int64 {
|
|||
// 必须作为原始内容透传给状态机,否则 /setcommands 的 /empty 永不可达、且首行
|
||||
// 带斜杠的命令列表会被截成命令名 "start" 静默销毁整个流程。
|
||||
var botFatherGlobalCommands = map[string]bool{
|
||||
"start": true, "help": true, "cancel": true,
|
||||
"start": true, "help": true, "cancel": true, botFatherCmdDone: true,
|
||||
botFatherCmdNewBot: true, "mybots": true,
|
||||
botFatherCmdToken: true, botFatherCmdRevoke: true,
|
||||
botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true,
|
||||
botFatherCmdSetCommands: true, botFatherCmdSetInline: true, botFatherCmdSetInlineGeo: true,
|
||||
botFatherCmdSetInlineFB: true, botFatherCmdSetJoinGroups: true, botFatherCmdSetPrivacy: true,
|
||||
botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true,
|
||||
}
|
||||
|
||||
func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply {
|
||||
|
|
@ -231,6 +243,9 @@ var pickerPrompts = map[string]string{
|
|||
botFatherCmdSetInlineGeo: "Choose a bot to change inline location requests for. Send the bot's username:",
|
||||
botFatherCmdSetJoinGroups: "Choose a bot to configure group joining for. Send the bot's username:",
|
||||
botFatherCmdSetPrivacy: "Choose a bot to configure group privacy for. Send the bot's username:",
|
||||
botFatherCmdSetLogin: "Choose a bot to configure Telegram Login for. Send the bot's username:",
|
||||
botFatherCmdLoginInfo: "Choose a bot whose Telegram Login configuration you want to inspect:",
|
||||
botFatherCmdResetLogin: "Choose a bot whose OIDC Client Secret you want to rotate:",
|
||||
}
|
||||
|
||||
// startBotPicker 列出 owner 的 bot 并进入 choose step(所有需先选 bot 的命令共用)。
|
||||
|
|
@ -277,7 +292,7 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
return botReply{Text: botFatherHelpText}
|
||||
case "cancel":
|
||||
_, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
|
|
@ -289,7 +304,12 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if state.Command == botFatherCmdSetLogin && state.Step == botFatherStepValue {
|
||||
return botReply{Text: "Telegram Login configuration closed. Changes that were already applied have been kept."}
|
||||
}
|
||||
return botReply{Text: "The command has been cancelled. Anything else I can do for you? Send /help for a list of commands."}
|
||||
case botFatherCmdDone:
|
||||
return s.finishTelegramLoginConfiguration(ctx, userID)
|
||||
case botFatherCmdNewBot:
|
||||
count, err := s.bots.CountBotsByOwner(ctx, userID)
|
||||
if err != nil {
|
||||
|
|
@ -322,7 +342,8 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
case botFatherCmdToken, botFatherCmdRevoke,
|
||||
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
|
||||
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy,
|
||||
botFatherCmdSetLogin, botFatherCmdLoginInfo, botFatherCmdResetLogin:
|
||||
return s.startBotPicker(ctx, userID, cmd)
|
||||
case botFatherCmdSetInlineFB:
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
|
|
@ -351,6 +372,8 @@ func valuePrompt(cmd, username string) string {
|
|||
return fmt.Sprintf("Send 'enable' to allow @%s to be added to groups, or 'disable' to prevent it.", username)
|
||||
case botFatherCmdSetPrivacy:
|
||||
return fmt.Sprintf("Send 'enable' to turn ON group privacy for @%s (it will only receive commands and replies), or 'disable' to let it receive all group messages.", username)
|
||||
case botFatherCmdSetLogin:
|
||||
return telegramLoginConfigurationPrompt(username)
|
||||
default:
|
||||
return "Send the new value, or /cancel."
|
||||
}
|
||||
|
|
@ -445,6 +468,61 @@ func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState
|
|||
}
|
||||
head := fmt.Sprintf("Token for @%s has been revoked. The old token will stop working immediately. New token:\n", chosen.Username)
|
||||
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
|
||||
case botFatherCmdLoginInfo:
|
||||
defer s.clearState(ctx, state.UserID)
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
configuration, found, err := s.telegramLogin.ClientConfiguration(ctx, chosen.ID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !found {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin to create it.", chosen.Username)}
|
||||
}
|
||||
return botReply{Text: formatTelegramLoginConfiguration(chosen.Username, configuration)}
|
||||
case botFatherCmdResetLogin:
|
||||
defer s.clearState(ctx, state.UserID)
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
credentials, err := s.telegramLogin.RotateClientSecret(ctx, chosen.ID)
|
||||
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin first.", chosen.Username)}
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("botfather: rotate telegram login secret", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
head := fmt.Sprintf("The previous OIDC Client Secret for @%s is now invalid. Save this new secret; it will only be shown once:\n", chosen.Username)
|
||||
return tokenReply(head, credentials.Secret, "\n\nClient ID: "+credentials.Client.ClientID)
|
||||
case botFatherCmdSetLogin:
|
||||
if s.telegramLogin == nil {
|
||||
s.clearState(ctx, state.UserID)
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
credentials, created, err := s.telegramLogin.EnsureClient(ctx, chosen.ID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: ensure telegram login client", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
state.Step = botFatherStepValue
|
||||
if state.Draft == nil {
|
||||
state.Draft = map[string]string{}
|
||||
}
|
||||
state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10)
|
||||
state.Draft[botFatherDraftBotUsername] = chosen.Username
|
||||
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
|
||||
s.log.Error("botfather: save telegram login state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
prompt := telegramLoginConfigurationPrompt(chosen.Username)
|
||||
if !created {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login client %s is ready for @%s.\n\n%s", credentials.Client.ClientID, chosen.Username, prompt)}
|
||||
}
|
||||
head := fmt.Sprintf("Telegram Login is now enabled for @%s.\nClient ID: %s\nSave this Client Secret; it will only be shown once:\n", chosen.Username, credentials.Client.ClientID)
|
||||
return tokenReply(head, credentials.Secret, "\n\n"+prompt)
|
||||
case botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
|
||||
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
|
||||
|
|
@ -507,6 +585,8 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
|
|||
reply, err = s.applyToggle(ctx, botID, text, true)
|
||||
case botFatherCmdSetPrivacy:
|
||||
reply, err = s.applyToggle(ctx, botID, text, false)
|
||||
case botFatherCmdSetLogin:
|
||||
return s.handleTelegramLoginConfigurationInput(ctx, state, botID, username, text)
|
||||
default:
|
||||
s.clearState(ctx, state.UserID)
|
||||
return internalReply()
|
||||
|
|
@ -583,6 +663,266 @@ func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text strin
|
|||
return botReply{Text: fmt.Sprintf("Success! Inline location requests are now %s.", state)}, nil
|
||||
}
|
||||
|
||||
func telegramLoginConfigurationPrompt(username string) string {
|
||||
return fmt.Sprintf(`Configure Telegram Login for @%s. Send commands one at a time or paste up to %d commands on separate lines:
|
||||
|
||||
add origin https://example.com
|
||||
add redirect https://example.com/auth/callback
|
||||
add ios com.example.app ABCDE12345 exampleapp://tglogin Example iOS App
|
||||
add android com.example.app AA:BB:...:FF exampleapp://telegram-login Example Android App
|
||||
remove origin https://example.com
|
||||
remove redirect https://example.com/auth/callback
|
||||
remove app 12
|
||||
algorithm RS256|ES256|EdDSA|ES256K
|
||||
enable
|
||||
disable
|
||||
|
||||
Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Changes apply immediately. Send /done to finish, or /cancel to close this session without undoing changes already applied.`, username, maxTelegramLoginCommandsPerMessage)
|
||||
}
|
||||
|
||||
func telegramLoginConfigurationContinuePrompt(username string) string {
|
||||
return fmt.Sprintf("Still configuring @%s. Send another command, paste multiple commands on separate lines, or send /done to finish.", username)
|
||||
}
|
||||
|
||||
func (s *Service) finishTelegramLoginConfiguration(ctx context.Context, userID int64) botReply {
|
||||
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get telegram login state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !found || state.Command != botFatherCmdSetLogin || state.Step != botFatherStepValue {
|
||||
return botReply{Text: "There is no active Telegram Login configuration to finish. Send /setlogin to start one."}
|
||||
}
|
||||
botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
|
||||
username := state.Draft[botFatherDraftBotUsername]
|
||||
if botID == 0 || username == "" {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: "Something went wrong, I forgot which bot we were editing. Send /setlogin to start again."}
|
||||
}
|
||||
owns, err := s.OwnsBot(ctx, userID, botID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: verify telegram login owner", zap.Int64("user_id", userID), zap.Int64("bot_user_id", botID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !owns {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: "That bot is no longer available."}
|
||||
}
|
||||
if s.telegramLogin == nil {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
configuration, configured, err := s.telegramLogin.ClientConfiguration(ctx, botID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", botID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !configured {
|
||||
s.clearState(ctx, userID)
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Send /setlogin to create it.", username)}
|
||||
}
|
||||
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
|
||||
s.log.Error("botfather: finish telegram login state", zap.Int64("user_id", userID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Finished configuring Telegram Login for @%s.\n\n%s", username, formatTelegramLoginConfiguration(username, configuration))}
|
||||
}
|
||||
|
||||
func (s *Service) handleTelegramLoginConfigurationInput(
|
||||
ctx context.Context,
|
||||
state domain.BotChatState,
|
||||
botID int64,
|
||||
username string,
|
||||
text string,
|
||||
) botReply {
|
||||
if strings.EqualFold(strings.TrimSpace(text), "done") {
|
||||
return s.finishTelegramLoginConfiguration(ctx, state.UserID)
|
||||
}
|
||||
lines := make([]string, 0, 4)
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
if line := strings.TrimSpace(raw); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return botReply{Text: "Send a Telegram Login configuration command.\n\n" + telegramLoginConfigurationContinuePrompt(username)}
|
||||
}
|
||||
if len(lines) > maxTelegramLoginCommandsPerMessage {
|
||||
return botReply{Text: fmt.Sprintf("Too many commands in one message. Send at most %d lines at a time.\n\n%s", maxTelegramLoginCommandsPerMessage, telegramLoginConfigurationContinuePrompt(username))}
|
||||
}
|
||||
|
||||
applied := make([]string, 0, len(lines))
|
||||
for i, line := range lines {
|
||||
reply, err := s.applyTelegramLoginConfiguration(ctx, botID, username, line)
|
||||
if err != nil {
|
||||
if len(lines) == 1 {
|
||||
if reply.Text == "" {
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: reply.Text + "\n\n" + telegramLoginConfigurationContinuePrompt(username)}
|
||||
}
|
||||
failure := reply.Text
|
||||
if failure == "" {
|
||||
failure = "Something went wrong on my side. Please try that line again later."
|
||||
}
|
||||
var out strings.Builder
|
||||
if len(applied) > 0 {
|
||||
fmt.Fprintf(&out, "Applied %d command(s) before the error:\n%s\n\n", len(applied), strings.Join(applied, "\n"))
|
||||
}
|
||||
fmt.Fprintf(&out, "Stopped at line %d:\n%s\n\n", i+1, failure)
|
||||
if i+1 < len(lines) {
|
||||
fmt.Fprintf(&out, "%d later command(s) were not applied.\n\n", len(lines)-i-1)
|
||||
}
|
||||
out.WriteString(telegramLoginConfigurationContinuePrompt(username))
|
||||
return botReply{Text: out.String()}
|
||||
}
|
||||
applied = append(applied, fmt.Sprintf("Line %d: %s", i+1, reply.Text))
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
if len(lines) == 1 {
|
||||
out.WriteString(strings.TrimPrefix(applied[0], "Line 1: "))
|
||||
} else {
|
||||
fmt.Fprintf(&out, "Applied all %d commands:\n%s", len(applied), strings.Join(applied, "\n"))
|
||||
}
|
||||
out.WriteString("\n\n")
|
||||
out.WriteString(telegramLoginConfigurationContinuePrompt(username))
|
||||
return botReply{Text: out.String()}
|
||||
}
|
||||
|
||||
func formatTelegramLoginConfiguration(username string, configuration telegramloginapp.ClientConfiguration) string {
|
||||
status := "disabled"
|
||||
if configuration.Client.Enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
var out strings.Builder
|
||||
fmt.Fprintf(&out, "Telegram Login for @%s\nClient ID: %s\nStatus: %s\nSigning algorithm: %s\nSecret version: %d",
|
||||
username, configuration.Client.ClientID, status, configuration.Client.SigningAlgorithm, configuration.Client.SecretVersion)
|
||||
if len(configuration.AllowedURLs) == 0 {
|
||||
out.WriteString("\nAllowed URLs: none")
|
||||
} else {
|
||||
out.WriteString("\nAllowed URLs:")
|
||||
for _, allowed := range configuration.AllowedURLs {
|
||||
fmt.Fprintf(&out, "\n- %s %s", allowed.Kind, allowed.NormalizedURL)
|
||||
}
|
||||
}
|
||||
if len(configuration.NativeApps) > 0 {
|
||||
out.WriteString("\nNative apps:")
|
||||
for _, app := range configuration.NativeApps {
|
||||
fmt.Fprintf(&out, "\n- #%d %s %s [%s] -> %s (%s)", app.ID, app.Platform, app.ApplicationID, app.VerificationID, app.CallbackURI, app.VerifiedDisplayName)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func telegramLoginAllowedURLKind(raw string) (domain.TelegramLoginAllowedURLKind, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "origin":
|
||||
return domain.TelegramLoginAllowedWebOrigin, true
|
||||
case "redirect":
|
||||
return domain.TelegramLoginAllowedRedirectURI, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func telegramLoginSigningAlgorithm(raw string) (domain.TelegramLoginSigningAlgorithm, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
||||
case "RS256":
|
||||
return domain.TelegramLoginSigningRS256, true
|
||||
case "ES256":
|
||||
return domain.TelegramLoginSigningES256, true
|
||||
case "EDDSA":
|
||||
return domain.TelegramLoginSigningEdDSA, true
|
||||
case "ES256K":
|
||||
return domain.TelegramLoginSigningES256K, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int64, username, text string) (botReply, error) {
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}, domain.ErrTelegramLoginClientDisabled
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(text))
|
||||
if len(fields) == 1 {
|
||||
switch strings.ToLower(fields[0]) {
|
||||
case "enable":
|
||||
if err := s.telegramLogin.SetClientEnabled(ctx, botID, true); err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s.", username)}, nil
|
||||
case "disable":
|
||||
if err := s.telegramLogin.SetClientEnabled(ctx, botID, false); err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is disabled for @%s. Pending requests can no longer be approved or exchanged.", username)}, nil
|
||||
}
|
||||
}
|
||||
if len(fields) == 2 && strings.EqualFold(fields[0], "algorithm") {
|
||||
algorithm, ok := telegramLoginSigningAlgorithm(fields[1])
|
||||
if !ok {
|
||||
return botReply{Text: "Unknown signing algorithm. Use RS256, ES256, EdDSA or ES256K, or /cancel."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
if _, err := s.telegramLogin.SetClientSigningAlgorithm(ctx, botID, algorithm); err != nil {
|
||||
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
return botReply{Text: fmt.Sprintf("%s is not available on this server because no active signing key is configured for it. Choose another algorithm or ask the operator to rotate the key ring.", algorithm)}, err
|
||||
}
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! New ID tokens for @%s will use %s. EdDSA and ES256K accept only the openid scope.", username, algorithm)}, nil
|
||||
}
|
||||
if len(fields) == 3 && (strings.EqualFold(fields[0], "add") || strings.EqualFold(fields[0], "remove")) &&
|
||||
(strings.EqualFold(fields[1], "origin") || strings.EqualFold(fields[1], "redirect")) {
|
||||
kind, ok := telegramLoginAllowedURLKind(fields[1])
|
||||
if !ok {
|
||||
return botReply{Text: "URL kind must be origin or redirect. Try again or /cancel."}, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if strings.EqualFold(fields[0], "add") {
|
||||
allowed, err := s.telegramLogin.AddAllowedURL(ctx, botID, kind, fields[2])
|
||||
if err != nil {
|
||||
return botReply{Text: "That URL is not allowed. Use an exact HTTP(S) URL permitted by this server without credentials, fragments or reserved OAuth query fields."}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Added %s for @%s:\n%s", allowed.Kind, username, allowed.NormalizedURL)}, nil
|
||||
}
|
||||
deleted, err := s.telegramLogin.DeleteAllowedURL(ctx, botID, kind, fields[2])
|
||||
if err != nil {
|
||||
return botReply{Text: "That URL is invalid. Try again or /cancel."}, err
|
||||
}
|
||||
if !deleted {
|
||||
return botReply{Text: "That exact URL was not registered. Check /logininfo and try again."}, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Removed %s from @%s.", kind, username)}, nil
|
||||
}
|
||||
if len(fields) >= 6 && strings.EqualFold(fields[0], "add") && (strings.EqualFold(fields[1], "ios") || strings.EqualFold(fields[1], "android")) {
|
||||
platform := domain.TelegramLoginNativeIOS
|
||||
if strings.EqualFold(fields[1], "android") {
|
||||
platform = domain.TelegramLoginNativeAndroid
|
||||
}
|
||||
app, err := s.telegramLogin.AddNativeApp(ctx, botID, platform, fields[2], fields[3], fields[4], strings.Join(fields[5:], " "))
|
||||
if err != nil {
|
||||
return botReply{Text: "Invalid native app registration. iOS needs Bundle ID + 10-character Team ID; Android needs package name + SHA-256 signing fingerprint. Use an exact HTTPS callback or a custom scheme://host callback."}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Registered native app #%d for @%s:\n%s %s -> %s", app.ID, username, app.Platform, app.ApplicationID, app.CallbackURI)}, nil
|
||||
}
|
||||
if len(fields) == 3 && strings.EqualFold(fields[0], "remove") && strings.EqualFold(fields[1], "app") {
|
||||
appID, err := strconv.ParseInt(fields[2], 10, 64)
|
||||
if err != nil || appID <= 0 {
|
||||
return botReply{Text: "Native app ID must be the positive number shown by /logininfo."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
deleted, err := s.telegramLogin.DeleteNativeApp(ctx, botID, appID)
|
||||
if err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
if !deleted {
|
||||
return botReply{Text: "That native app was not registered for this bot. Check /logininfo."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Removed native app #%d from @%s.", appID, username)}, nil
|
||||
}
|
||||
return botReply{Text: telegramLoginConfigurationPrompt(username)}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
|
||||
// applyToggle 解析 enable/disable 并设置 joingroups(join=true)或 privacy(join=false)。
|
||||
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
|
||||
var on bool
|
||||
|
|
|
|||
167
internal/app/bots/botfather_login_test.go
Normal file
167
internal/app/bots/botfather_login_test.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service {
|
||||
t.Helper()
|
||||
sealKey := make([]byte, 32)
|
||||
sealKey[0] = 1
|
||||
sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 2
|
||||
service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{
|
||||
Issuer: "http://192.0.2.25:2404", AppScheme: "telesrv", AllowHTTP: true,
|
||||
ClientSecretPepper: pepper, Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
svc.telegramLogin = newBotFatherTelegramLoginService(t)
|
||||
owner := newOwner(t, users, "+1090")
|
||||
bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Login Demo", "login_demo_bot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/setlogin"); !strings.Contains(reply, "Choose a bot") {
|
||||
t.Fatalf("/setlogin reply = %q", reply)
|
||||
}
|
||||
created := sendToBotFather(t, svc, messages, owner, "@login_demo_bot")
|
||||
if !strings.Contains(created, "Client ID: "+strconv.FormatInt(bot.ID, 10)) || !strings.Contains(created, "only be shown once") {
|
||||
t.Fatalf("create login reply = %q", created)
|
||||
}
|
||||
secretMarker := "only be shown once:\n"
|
||||
secret := strings.SplitN(strings.SplitN(created, secretMarker, 2)[1], "\n", 2)[0]
|
||||
if len(secret) < 32 {
|
||||
t.Fatalf("client secret is unexpectedly short: %q", secret)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://rp.example.test:3000"); !strings.Contains(reply, "Success!") {
|
||||
t.Fatalf("add origin reply = %q", reply)
|
||||
}
|
||||
state, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID)
|
||||
if err != nil || !found || state.Step != botFatherStepValue || state.Draft[botFatherDraftBotID] != strconv.FormatInt(bot.ID, 10) {
|
||||
t.Fatalf("state after first command = %+v, found=%v err=%v", state, found, err)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://192.0.2.26:3000/auth/callback"); !strings.Contains(reply, "Success!") {
|
||||
t.Fatalf("add redirect reply = %q", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "algorithm ES256"); !strings.Contains(reply, "ES256") {
|
||||
t.Fatalf("algorithm reply = %q", reply)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add ios dev.bedolaga.demo ABCDE12345 bedolaga://telegram-login Bedolaga iOS Demo"); !strings.Contains(reply, "Registered native app #") {
|
||||
t.Fatalf("add iOS app reply = %q", reply)
|
||||
}
|
||||
fingerprint := strings.Repeat("A", 64)
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add android dev.bedolaga.demo "+fingerprint+" bedolaga://android-login Bedolaga Android Demo"); !strings.Contains(reply, "Registered native app #") {
|
||||
t.Fatalf("add Android app reply = %q", reply)
|
||||
}
|
||||
done := sendToBotFather(t, svc, messages, owner, "/done")
|
||||
if !strings.Contains(done, "Finished configuring") || !strings.Contains(done, "Signing algorithm: ES256") {
|
||||
t.Fatalf("/done reply = %q", done)
|
||||
}
|
||||
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || found {
|
||||
t.Fatalf("state after /done: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/logininfo")
|
||||
info := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
for _, want := range []string{"Signing algorithm: ES256", "web_origin http://rp.example.test:3000", "redirect_uri http://192.0.2.26:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} {
|
||||
if !strings.Contains(info, want) {
|
||||
t.Fatalf("login info = %q, missing %q", info, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(info, secret) {
|
||||
t.Fatal("/logininfo leaked the one-time client secret")
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/resetloginsecret")
|
||||
rotated := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
if !strings.Contains(rotated, "previous OIDC Client Secret") || strings.Contains(rotated, secret) {
|
||||
t.Fatalf("rotate reply = %q", rotated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFatherTelegramLoginBatchAndCancelFlow(t *testing.T) {
|
||||
svc, users, bots, messages := newTestService(t)
|
||||
svc.telegramLogin = newBotFatherTelegramLoginService(t)
|
||||
owner := newOwner(t, users, "+1091")
|
||||
bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Batch Login Demo", "batch_login_bot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/done"); !strings.Contains(reply, "no active") {
|
||||
t.Fatalf("inactive /done reply = %q", reply)
|
||||
}
|
||||
sendToBotFather(t, svc, messages, owner, "/setlogin")
|
||||
sendToBotFather(t, svc, messages, owner, "@batch_login_bot")
|
||||
tooMany := strings.TrimSuffix(strings.Repeat("enable\n", maxTelegramLoginCommandsPerMessage+1), "\n")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, tooMany); !strings.Contains(reply, "at most 32 lines") {
|
||||
t.Fatalf("oversized batch reply = %q", reply)
|
||||
}
|
||||
oversizedConfiguration, found, err := svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || len(oversizedConfiguration.AllowedURLs) != 0 || oversizedConfiguration.Client.SigningAlgorithm != "RS256" {
|
||||
t.Fatalf("configuration after oversized batch = %+v, found=%v err=%v", oversizedConfiguration, found, err)
|
||||
}
|
||||
batch := strings.Join([]string{
|
||||
"add origin http://batch.example.test:3000",
|
||||
"add redirect http://batch.example.test:3000/auth/telegram/callback",
|
||||
"algorithm ES256",
|
||||
"enable",
|
||||
}, "\n")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, batch); !strings.Contains(reply, "Applied all 4 commands") || !strings.Contains(reply, "/done") {
|
||||
t.Fatalf("batch reply = %q", reply)
|
||||
}
|
||||
configuration, found, err := svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || !configuration.Client.Enabled || configuration.Client.SigningAlgorithm != "ES256" || len(configuration.AllowedURLs) != 2 {
|
||||
t.Fatalf("configuration after batch = %+v, found=%v err=%v", configuration, found, err)
|
||||
}
|
||||
|
||||
partial := strings.Join([]string{
|
||||
"add origin http://second.example.test:3001",
|
||||
"add redirect not-a-url",
|
||||
"disable",
|
||||
}, "\n")
|
||||
partialReply := sendToBotFather(t, svc, messages, owner, partial)
|
||||
for _, want := range []string{"Applied 1 command(s) before the error", "Stopped at line 2", "1 later command(s) were not applied", "/done"} {
|
||||
if !strings.Contains(partialReply, want) {
|
||||
t.Fatalf("partial batch reply = %q, missing %q", partialReply, want)
|
||||
}
|
||||
}
|
||||
configuration, found, err = svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || !configuration.Client.Enabled || len(configuration.AllowedURLs) != 3 {
|
||||
t.Fatalf("configuration after partial batch = %+v, found=%v err=%v", configuration, found, err)
|
||||
}
|
||||
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || !found {
|
||||
t.Fatalf("state after partial batch: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "already applied have been kept") {
|
||||
t.Fatalf("/cancel reply = %q", reply)
|
||||
}
|
||||
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || found {
|
||||
t.Fatalf("state after /cancel: found=%v err=%v", found, err)
|
||||
}
|
||||
configuration, found, err = svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
|
||||
if err != nil || !found || len(configuration.AllowedURLs) != 3 {
|
||||
t.Fatalf("configuration after /cancel = %+v, found=%v err=%v", configuration, found, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package bots
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -372,6 +373,38 @@ func TestRevokeBotTokenRevokesSessions(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDeleteBotFailsClosedWhenSessionRevocationFails(t *testing.T) {
|
||||
users := memory.NewUserStore()
|
||||
botStore := &countingBotStore{BotStore: memory.NewBotStore(users)}
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
revocationErr := errors.New("authorization store unavailable")
|
||||
rev := &captureRevoker{err: revocationErr}
|
||||
svc := NewService(users, botStore, messages)
|
||||
svc.SetRouterHooks(rev)
|
||||
owner := newOwner(t, users, "+2099")
|
||||
bot := makeBot(t, svc, owner, "Delete Guard Bot", "delete_guard_bot")
|
||||
|
||||
if _, err := svc.DeleteBot(context.Background(), bot.ID); !errors.Is(err, domain.ErrBotSessionsNotRevoked) {
|
||||
t.Fatalf("DeleteBot error=%v, want ErrBotSessionsNotRevoked", err)
|
||||
}
|
||||
if botStore.deleteCalls != 0 {
|
||||
t.Fatalf("DeleteBotAccount calls=%d after failed session revocation", botStore.deleteCalls)
|
||||
}
|
||||
if _, found, err := botStore.GetBot(context.Background(), bot.ID); err != nil || !found {
|
||||
t.Fatalf("bot disappeared after failed revocation: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
rev.err = nil
|
||||
deleted, err := svc.DeleteBot(context.Background(), bot.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteBot after revocation recovery: %v", err)
|
||||
}
|
||||
if botStore.deleteCalls != 1 || deleted.ID != bot.ID || !deleted.Deleted {
|
||||
t.Fatalf("deleted=%+v deleteCalls=%d", deleted, botStore.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotWriteAccessGrant(t *testing.T) {
|
||||
svc, users, _, _ := newTestService(t)
|
||||
owner := newOwner(t, users, "+2012")
|
||||
|
|
@ -400,11 +433,12 @@ type captureRevoker struct {
|
|||
botUserID int64
|
||||
pushedCommandsTo int64
|
||||
pushedCommands []domain.BotCommand
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *captureRevoker) RevokeBotSessions(_ context.Context, botUserID int64) error {
|
||||
c.botUserID = botUserID
|
||||
return nil
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int64, commands []domain.BotCommand) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -82,6 +83,7 @@ type Service struct {
|
|||
stickers stickerSetCreator
|
||||
installer userStickerSetInstaller
|
||||
aiChat aiChatGenerator
|
||||
telegramLogin *telegramloginapp.Service
|
||||
hooks RouterHooks
|
||||
textDrafts TextDraftPusher
|
||||
userCache store.UserCache
|
||||
|
|
@ -175,6 +177,16 @@ func WithAIChatGenerator(g aiChatGenerator) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithTelegramLogin injects the OIDC application service used by BotFather.
|
||||
// BotFather never writes the login tables directly.
|
||||
func WithTelegramLogin(login *telegramloginapp.Service) Option {
|
||||
return func(s *Service) {
|
||||
if login != nil {
|
||||
s.telegramLogin = login
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAIChatStreamThrottle 调整 @ChatBot 流式草稿推送的最小时间间隔(测试用)。
|
||||
func WithAIChatStreamThrottle(d time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
|
|
@ -436,6 +448,45 @@ func (s *Service) ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domai
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// botAccountDeleter is the optional store capability used to permanently delete
|
||||
// a user-created bot. Only the Postgres store implements it, so the memory store
|
||||
// and other BotStore mocks are unaffected.
|
||||
type botAccountDeleter interface {
|
||||
DeleteBotAccount(ctx context.Context, botUserID int64) (domain.User, error)
|
||||
}
|
||||
|
||||
// DeleteBot permanently removes a user-created bot. System service bots are
|
||||
// rejected. Live sessions are dropped and the bot's caches are invalidated so
|
||||
// the deletion is visible immediately. Returns the tombstoned user.
|
||||
func (s *Service) DeleteBot(ctx context.Context, botUserID int64) (domain.User, error) {
|
||||
if s == nil || s.bots == nil || botUserID == 0 {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
if domain.IsSystemUserID(botUserID) {
|
||||
return domain.User{}, domain.ErrBotNotFound
|
||||
}
|
||||
deleter, ok := s.bots.(botAccountDeleter)
|
||||
if !ok {
|
||||
return domain.User{}, fmt.Errorf("bot deletion is not supported by the configured store")
|
||||
}
|
||||
// Session revocation is part of the deletion invariant: a deleted bot must
|
||||
// never retain an authenticated connection. Fail closed before tombstoning
|
||||
// when the hook is unavailable or revocation fails.
|
||||
if s.hooks == nil {
|
||||
return domain.User{}, domain.ErrBotSessionsNotRevoked
|
||||
}
|
||||
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
|
||||
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err))
|
||||
return domain.User{}, domain.ErrBotSessionsNotRevoked
|
||||
}
|
||||
u, err := deleter.DeleteBotAccount(ctx, botUserID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.invalidateBotReadCaches(ctx, botUserID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ExportBotToken 返回 bot token;revoke=true 时先轮换 secret 并撤销已登录 session。
|
||||
func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) {
|
||||
if revoke {
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ type countingBotStore struct {
|
|||
*memory.BotStore
|
||||
getBotCalls int
|
||||
getBotsCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func (s *countingBotStore) reset() {
|
||||
|
|
@ -198,6 +199,11 @@ func (s *countingBotStore) GetBots(ctx context.Context, botUserIDs []int64) (map
|
|||
return s.BotStore.GetBots(ctx, botUserIDs)
|
||||
}
|
||||
|
||||
func (s *countingBotStore) DeleteBotAccount(_ context.Context, botUserID int64) (domain.User, error) {
|
||||
s.deleteCalls++
|
||||
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
|
||||
}
|
||||
|
||||
func TestBotFatherCancelAndUnknown(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
owner := newOwner(t, users, "+1001")
|
||||
|
|
|
|||
|
|
@ -754,6 +754,16 @@ func normalizeStickersBotShortName(raw string) string {
|
|||
raw = strings.TrimPrefix(raw, "tg://addemoji?set=")
|
||||
if strings.Contains(raw, "://") {
|
||||
if parsed, err := url.Parse(raw); err == nil {
|
||||
query := parsed.Query()
|
||||
route := strings.Trim(parsed.Path, "/")
|
||||
if route == "" {
|
||||
route = strings.ToLower(parsed.Host)
|
||||
}
|
||||
if route == "addstickers" || route == "addemoji" {
|
||||
if shortName := query.Get("set"); shortName != "" {
|
||||
raw = shortName
|
||||
}
|
||||
}
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if (part == "addstickers" || part == "addemoji") && i+1 < len(parts) {
|
||||
|
|
|
|||
|
|
@ -649,3 +649,19 @@ func (h *stickersBotHookRecorder) PushStickerSetsChanged(_ context.Context, user
|
|||
h.userID = userID
|
||||
h.kind = kind
|
||||
}
|
||||
|
||||
func TestNormalizeStickersBotShortNameAcceptsHostBasedAppLinks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{raw: "telesrv://addstickers?set=Legacy_Pack", want: "legacy_pack"},
|
||||
{raw: "owpg://tenant.example.test/addstickers?set=Hosted_Pack", want: "hosted_pack"},
|
||||
{raw: "owpg://tenant.example.test/addemoji?set=Emoji_Pack", want: "emoji_pack"},
|
||||
{raw: "https://telesrv.net/addstickers/Web_Pack", want: "web_pack"},
|
||||
} {
|
||||
if got := normalizeStickersBotShortName(tc.raw); got != tc.want {
|
||||
t.Fatalf("normalizeStickersBotShortName(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -500,6 +500,49 @@ func (s *Service) SetVerified(ctx context.Context, channelID int64, verified boo
|
|||
return s.channels.SetChannelVerified(ctx, channelID, verified)
|
||||
}
|
||||
|
||||
// SetScamFake sets or clears the channel/supergroup scam and fake flags through the internal admin path.
|
||||
func (s *Service) SetScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if scam && fake {
|
||||
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
return s.channels.SetChannelScamFake(ctx, channelID, scam, fake)
|
||||
}
|
||||
|
||||
// AdminSetSettings applies a moderation-settings patch through the admin path (no permission checks).
|
||||
func (s *Service) AdminSetSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelAdminSettings(ctx, channelID, patch)
|
||||
}
|
||||
|
||||
// AdminSetUsername force-sets or clears a channel username through the admin path.
|
||||
func (s *Service) AdminSetUsername(ctx context.Context, channelID int64, username string) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelUsernameAdmin(ctx, channelID, username)
|
||||
}
|
||||
|
||||
// AdminSetColor force-sets a channel name/profile color through the admin path.
|
||||
func (s *Service) AdminSetColor(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelColorAdmin(ctx, channelID, forProfile, color)
|
||||
}
|
||||
|
||||
// AdminSetEmojiStatus force-sets or clears a channel emoji status through the admin path.
|
||||
func (s *Service) AdminSetEmojiStatus(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelEmojiStatusAdmin(ctx, channelID, status)
|
||||
}
|
||||
|
||||
// ListAdminedPublicChannels returns public channels/supergroups administered by user.
|
||||
func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
|
|||
34
internal/app/channels/service_suggested_post.go
Normal file
34
internal/app/channels/service_suggested_post.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type suggestedPostStore interface {
|
||||
ToggleSuggestedPostApproval(context.Context, domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error)
|
||||
ProcessSuggestedPostLifecycle(context.Context, domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error)
|
||||
}
|
||||
|
||||
func (s *Service) ToggleSuggestedPostApproval(ctx context.Context, req domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if s == nil || s.channels == nil || req.UserID == 0 || req.MonoforumID == 0 || req.MessageID <= 0 {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
store, ok := s.channels.(suggestedPostStore)
|
||||
if !ok {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return store.ToggleSuggestedPostApproval(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) ProcessSuggestedPostLifecycle(ctx context.Context, req domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if s == nil || s.channels == nil {
|
||||
return nil, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
store, ok := s.channels.(suggestedPostStore)
|
||||
if !ok {
|
||||
return nil, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return store.ProcessSuggestedPostLifecycle(ctx, req)
|
||||
}
|
||||
|
|
@ -132,5 +132,8 @@ func cloneUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type Service struct {
|
|||
users store.UserStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy phonePrivacyService
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
cache *contactListReadModelCache
|
||||
|
|
@ -49,6 +50,10 @@ func WithPrivacyEvaluator(p phonePrivacyService) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable hash-token fast paths for NotModified RPCs.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
|
|
@ -84,6 +89,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -402,6 +402,7 @@ func cloneDialogMessages(in []domain.Message) []domain.Message {
|
|||
|
||||
func cloneMessageForDialogCache(msg domain.Message) domain.Message {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
msg.RichMessage = cloneRichMessage(msg.RichMessage)
|
||||
if msg.ReplyTo != nil {
|
||||
reply := *msg.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
|
||||
|
|
@ -424,6 +425,7 @@ func cloneDialogChannelMessages(in []domain.ChannelMessage) []domain.ChannelMess
|
|||
|
||||
func cloneChannelMessageForDialogCache(msg domain.ChannelMessage) domain.ChannelMessage {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
msg.RichMessage = cloneRichMessage(msg.RichMessage)
|
||||
if msg.ReplyTo != nil {
|
||||
reply := *msg.ReplyTo
|
||||
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
|
||||
|
|
@ -500,6 +502,9 @@ func cloneDialogUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
premium PremiumChecker
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
|
|
@ -54,6 +55,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable version-token backed peer dialog caching.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
|
|
@ -93,6 +98,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -946,6 +952,7 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
|
|||
clone.Blocks = append([]byte(nil), m.Blocks...)
|
||||
clone.Photos = append([]domain.Photo(nil), m.Photos...)
|
||||
clone.Documents = append([]domain.Document(nil), m.Documents...)
|
||||
clone.BotAPIProjection = append([]byte(nil), m.BotAPIProjection...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
|
|
|
|||
79
internal/app/files/emoji_animation.go
Normal file
79
internal/app/files/emoji_animation.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const maxEmojiAnimationBytes = 2 << 20
|
||||
|
||||
// DocumentAnimationJSON returns the Lottie JSON for an animated custom-emoji
|
||||
// document, decompressing TGS (gzip) transparently. Non-emoji documents and
|
||||
// documents without a stored blob return found=false. It backs the admin emoji
|
||||
// browser preview and reuses the existing file-blob storage (doc:<id> key).
|
||||
func (s *Service) DocumentAnimationJSON(ctx context.Context, documentID int64) ([]byte, bool, error) {
|
||||
if s == nil || s.media == nil || s.blobs == nil || documentID <= 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
doc, found, err := s.GetDocument(ctx, documentID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !found || !documentIsCustomEmoji(doc) {
|
||||
return nil, false, nil
|
||||
}
|
||||
blob, found, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", documentID))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !found || blob.Size <= 0 || blob.Size > maxEmojiAnimationBytes {
|
||||
return nil, false, nil
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if int64(len(data)) != total {
|
||||
return nil, false, nil
|
||||
}
|
||||
out, err := gunzipIfNeeded(data)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
func documentIsCustomEmoji(doc domain.Document) bool {
|
||||
for _, a := range doc.Attributes {
|
||||
if a.Kind == domain.DocAttrCustomEmoji {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gunzipIfNeeded transparently decompresses TGS (gzip-wrapped Lottie); raw JSON
|
||||
// (non-gzip) is returned unchanged.
|
||||
func gunzipIfNeeded(data []byte) ([]byte, error) {
|
||||
if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b {
|
||||
return data, nil
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open tgs gzip: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
out, err := io.ReadAll(io.LimitReader(gz, maxEmojiAnimationBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decompress tgs: %w", err)
|
||||
}
|
||||
if len(out) > maxEmojiAnimationBytes {
|
||||
return nil, fmt.Errorf("decompressed tgs too large")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -56,9 +56,9 @@ const tdesktopClient = "tdesktop"
|
|||
//
|
||||
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles,
|
||||
// so this compatibility key must always remain an array, even when it is empty.
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||
|
||||
const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
const defaultAppConfigHash = 24 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
|
||||
// Service 提供客户端启动配置与国家区号目录。
|
||||
//
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
|||
"reactions_user_max_default": 1,
|
||||
"reactions_user_max_premium": 3,
|
||||
"boosts_channel_level_max": 100,
|
||||
"stargifts_pinned_to_top_limit": 6,
|
||||
"about_length_limit_default": 70,
|
||||
"about_length_limit_premium": 140,
|
||||
"dialogs_pinned_limit_default": 5,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
versions store.ReadModelVersionStore
|
||||
projector *userprojection.Projector
|
||||
botResponder BotResponder
|
||||
|
|
@ -57,6 +58,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithBotResponder 启用服务端内置 bot(BotFather)对私聊消息的自动应答。
|
||||
func WithBotResponder(r BotResponder) Option {
|
||||
return func(s *Service) { s.botResponder = r }
|
||||
|
|
@ -85,6 +90,7 @@ func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,18 +132,18 @@ func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testi
|
|||
},
|
||||
Collectible: &domain.StarGiftCollectibleWrite{
|
||||
UpgradeStars: 100, SupplyTotal: 1000, SlugPrefix: "official-10",
|
||||
Models: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleModel, Name: "Model", RarityKind: domain.StarGiftRarityPermille,
|
||||
RarityPermille: 1000, Animation: &animation,
|
||||
}},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille,
|
||||
RarityPermille: 1000, Animation: &animation,
|
||||
}},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{{
|
||||
Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", RarityKind: domain.StarGiftRarityPermille,
|
||||
RarityPermille: 1000,
|
||||
}},
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Model Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500, Animation: &animation},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 1, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 2, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 500},
|
||||
},
|
||||
Actor: "test", CommandID: "official-pool", OfficialGiftID: 10,
|
||||
SourceManifestSHA256: manifestSHA,
|
||||
},
|
||||
|
|
@ -151,7 +151,7 @@ func TestCreateCatalogBundleMaterializesPublishableCollectibleDocuments(t *testi
|
|||
if err != nil {
|
||||
t.Fatalf("create official collectible bundle: %v", err)
|
||||
}
|
||||
if result.Collectible == nil || len(result.Collectible.Models) != 1 || len(result.Collectible.Patterns) != 1 {
|
||||
if result.Collectible == nil || len(result.Collectible.Models) != 2 || len(result.Collectible.Patterns) != 2 {
|
||||
t.Fatalf("collectible result = %+v", result.Collectible)
|
||||
}
|
||||
model := result.Collectible.Models[0].Document
|
||||
|
|
|
|||
|
|
@ -517,6 +517,18 @@ func (s *Service) UpgradeReceipt(ctx context.Context, userID int64, commandKey s
|
|||
return s.upgrades.StarGiftUpgradeReceipt(ctx, userID, commandKey)
|
||||
}
|
||||
|
||||
// GrantUnique atomically assigns a freshly minted collectible to a user.
|
||||
func (s *Service) GrantUnique(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) {
|
||||
if s == nil || s.upgrades == nil {
|
||||
return domain.AdminStarGiftGrantResult{}, fmt.Errorf("star gift upgrade store is not configured")
|
||||
}
|
||||
result, err := s.upgrades.GrantUniqueStarGift(ctx, req)
|
||||
if err == nil {
|
||||
s.InvalidateStarGiftCatalog()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable
|
||||
|
|
|
|||
131
internal/app/telegramlogin/crypto.go
Normal file
131
internal/app/telegramlogin/crypto.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const opaqueTokenBytes = 32
|
||||
|
||||
func GenerateOpaqueToken() (string, error) {
|
||||
raw := make([]byte, opaqueTokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate opaque token: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func HashOpaqueToken(token string) []byte {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func HashClientSecret(pepper []byte, secret string) ([]byte, error) {
|
||||
if len(pepper) < 32 || secret == "" {
|
||||
return nil, domain.ErrTelegramLoginSecretInvalid
|
||||
}
|
||||
mac := hmac.New(sha256.New, pepper)
|
||||
_, _ = mac.Write([]byte(secret))
|
||||
return mac.Sum(nil), nil
|
||||
}
|
||||
|
||||
func VerifyClientSecret(pepper []byte, secret string, expected []byte) bool {
|
||||
actual, err := HashClientSecret(pepper, secret)
|
||||
if err != nil || len(expected) != sha256.Size {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||
}
|
||||
|
||||
func PKCEChallenge(verifier string) (string, error) {
|
||||
if len(verifier) < 43 || len(verifier) > 128 {
|
||||
return "", domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
for i := 0; i < len(verifier); i++ {
|
||||
c := verifier[i]
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~') {
|
||||
return "", domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func ValidatePKCEChallenge(challenge, method string) error {
|
||||
if method != "S256" || len(challenge) < 43 || len(challenge) > 128 {
|
||||
return domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
for i := 0; i < len(challenge); i++ {
|
||||
c := challenge[i]
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CodeSealer struct {
|
||||
activeKeyID string
|
||||
keys map[string]cipher.AEAD
|
||||
}
|
||||
|
||||
func NewCodeSealer(activeKeyID string, rawKeys map[string][]byte) (*CodeSealer, error) {
|
||||
if activeKeyID == "" || len(rawKeys) == 0 {
|
||||
return nil, errors.New("telegram login code seal key ring is empty")
|
||||
}
|
||||
keys := make(map[string]cipher.AEAD, len(rawKeys))
|
||||
for keyID, raw := range rawKeys {
|
||||
if keyID == "" || len(raw) != 32 {
|
||||
return nil, fmt.Errorf("invalid telegram login code seal key %q", keyID)
|
||||
}
|
||||
block, err := aes.NewCipher(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create telegram login code seal key %q: %w", keyID, err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create telegram login code sealer %q: %w", keyID, err)
|
||||
}
|
||||
keys[keyID] = aead
|
||||
}
|
||||
if _, ok := keys[activeKeyID]; !ok {
|
||||
return nil, fmt.Errorf("active telegram login code seal key %q not found", activeKeyID)
|
||||
}
|
||||
return &CodeSealer{activeKeyID: activeKeyID, keys: keys}, nil
|
||||
}
|
||||
|
||||
func (s *CodeSealer) Seal(plaintext string, aad []byte) (sealed, nonce []byte, keyID string, err error) {
|
||||
if s == nil || plaintext == "" {
|
||||
return nil, nil, "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
aead := s.keys[s.activeKeyID]
|
||||
nonce = make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("generate telegram login code nonce: %w", err)
|
||||
}
|
||||
return aead.Seal(nil, nonce, []byte(plaintext), aad), nonce, s.activeKeyID, nil
|
||||
}
|
||||
|
||||
func (s *CodeSealer) Open(sealed, nonce []byte, keyID string, aad []byte) (string, error) {
|
||||
if s == nil {
|
||||
return "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
aead, ok := s.keys[keyID]
|
||||
if !ok || len(nonce) != aead.NonceSize() {
|
||||
return "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
plaintext, err := aead.Open(nil, nonce, sealed, aad)
|
||||
if err != nil || len(plaintext) == 0 {
|
||||
return "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
407
internal/app/telegramlogin/jose.go
Normal file
407
internal/app/telegramlogin/jose.go
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const defaultIDTokenTTL = time.Hour
|
||||
|
||||
type SigningKeyMaterial struct {
|
||||
Algorithm domain.TelegramLoginSigningAlgorithm
|
||||
KeyID string
|
||||
PrivateKey any
|
||||
Active bool
|
||||
PublishUntil time.Time
|
||||
}
|
||||
|
||||
type signingKey struct {
|
||||
algorithm domain.TelegramLoginSigningAlgorithm
|
||||
jwaAlgorithm jwa.SignatureAlgorithm
|
||||
keyID string
|
||||
private jwk.Key
|
||||
public jwk.Key
|
||||
active bool
|
||||
publishUntil time.Time
|
||||
}
|
||||
|
||||
// SigningKeyRing owns no mutable crypto state. Rotation is performed by
|
||||
// constructing a new ring containing the new active key and old public keys
|
||||
// with a PublishUntil at least as long as the maximum ID-token lifetime.
|
||||
type SigningKeyRing struct {
|
||||
keys []signingKey
|
||||
active map[domain.TelegramLoginSigningAlgorithm]signingKey
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewSigningKeyRing(materials []SigningKeyMaterial, now func() time.Time) (*SigningKeyRing, error) {
|
||||
if len(materials) == 0 {
|
||||
return nil, errors.New("telegram login signing key ring is empty")
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
ring := &SigningKeyRing{
|
||||
keys: make([]signingKey, 0, len(materials)),
|
||||
active: make(map[domain.TelegramLoginSigningAlgorithm]signingKey),
|
||||
now: now,
|
||||
}
|
||||
seenKeyIDs := make(map[string]struct{}, len(materials))
|
||||
for _, material := range materials {
|
||||
key, err := importSigningKey(material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, duplicate := seenKeyIDs[key.keyID]; duplicate {
|
||||
return nil, fmt.Errorf("duplicate telegram login signing kid %q", key.keyID)
|
||||
}
|
||||
seenKeyIDs[key.keyID] = struct{}{}
|
||||
if key.active {
|
||||
if _, duplicate := ring.active[key.algorithm]; duplicate {
|
||||
return nil, fmt.Errorf("multiple active telegram login signing keys for %s", key.algorithm)
|
||||
}
|
||||
ring.active[key.algorithm] = key
|
||||
}
|
||||
ring.keys = append(ring.keys, key)
|
||||
}
|
||||
if len(ring.active) == 0 {
|
||||
return nil, errors.New("telegram login signing key ring has no active key")
|
||||
}
|
||||
return ring, nil
|
||||
}
|
||||
|
||||
func importSigningKey(material SigningKeyMaterial) (signingKey, error) {
|
||||
if !material.Algorithm.Valid() || material.PrivateKey == nil {
|
||||
return signingKey{}, fmt.Errorf("invalid telegram login signing key material")
|
||||
}
|
||||
if material.Algorithm == domain.TelegramLoginSigningES256K && !telegramLoginES256KEnabled {
|
||||
return signingKey{}, errors.New("telegram login ES256K requires a build with -tags jwx_es256k")
|
||||
}
|
||||
if err := validateRawSigningKey(material.Algorithm, material.PrivateKey); err != nil {
|
||||
return signingKey{}, err
|
||||
}
|
||||
privateKey, err := jwk.Import(material.PrivateKey)
|
||||
if err != nil {
|
||||
return signingKey{}, fmt.Errorf("import telegram login %s private key: %w", material.Algorithm, err)
|
||||
}
|
||||
if err := privateKey.Validate(); err != nil {
|
||||
return signingKey{}, fmt.Errorf("validate telegram login %s private JWK: %w", material.Algorithm, err)
|
||||
}
|
||||
publicKey, err := privateKey.PublicKey()
|
||||
if err != nil {
|
||||
return signingKey{}, fmt.Errorf("derive telegram login %s public JWK: %w", material.Algorithm, err)
|
||||
}
|
||||
thumbprint, err := publicKey.Thumbprint(crypto.SHA256)
|
||||
if err != nil {
|
||||
return signingKey{}, fmt.Errorf("thumbprint telegram login %s public JWK: %w", material.Algorithm, err)
|
||||
}
|
||||
keyID := strings.TrimSpace(material.KeyID)
|
||||
if keyID == "" {
|
||||
keyID = base64.RawURLEncoding.EncodeToString(thumbprint)
|
||||
}
|
||||
if len(keyID) > 128 || strings.IndexFunc(keyID, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 {
|
||||
return signingKey{}, fmt.Errorf("invalid telegram login signing kid")
|
||||
}
|
||||
jwaAlgorithm, err := telegramLoginJWA(material.Algorithm)
|
||||
if err != nil {
|
||||
return signingKey{}, err
|
||||
}
|
||||
for _, key := range []jwk.Key{privateKey, publicKey} {
|
||||
if err := key.Set(jwk.KeyIDKey, keyID); err != nil {
|
||||
return signingKey{}, fmt.Errorf("set telegram login signing kid: %w", err)
|
||||
}
|
||||
if err := key.Set(jwk.AlgorithmKey, jwaAlgorithm); err != nil {
|
||||
return signingKey{}, fmt.Errorf("set telegram login signing algorithm: %w", err)
|
||||
}
|
||||
if err := key.Set(jwk.KeyUsageKey, "sig"); err != nil {
|
||||
return signingKey{}, fmt.Errorf("set telegram login signing use: %w", err)
|
||||
}
|
||||
}
|
||||
return signingKey{
|
||||
algorithm: material.Algorithm, jwaAlgorithm: jwaAlgorithm, keyID: keyID,
|
||||
private: privateKey, public: publicKey, active: material.Active,
|
||||
publishUntil: material.PublishUntil.UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateRawSigningKey(algorithm domain.TelegramLoginSigningAlgorithm, raw any) error {
|
||||
switch algorithm {
|
||||
case domain.TelegramLoginSigningRS256:
|
||||
key, ok := rsaPrivateKey(raw)
|
||||
if !ok || key.N == nil || key.N.BitLen() < 2048 || key.E < 3 {
|
||||
return errors.New("telegram login RS256 requires an RSA private key of at least 2048 bits")
|
||||
}
|
||||
if err := key.Validate(); err != nil {
|
||||
return fmt.Errorf("validate telegram login RSA private key: %w", err)
|
||||
}
|
||||
case domain.TelegramLoginSigningES256:
|
||||
key, ok := ecdsaPrivateKey(raw)
|
||||
if !ok || key.Curve != elliptic.P256() || key.D == nil || key.X == nil || key.Y == nil {
|
||||
return errors.New("telegram login ES256 requires a P-256 ECDSA private key")
|
||||
}
|
||||
case domain.TelegramLoginSigningEdDSA:
|
||||
key, ok := raw.(ed25519.PrivateKey)
|
||||
if !ok || len(key) != ed25519.PrivateKeySize {
|
||||
return errors.New("telegram login EdDSA requires an Ed25519 private key")
|
||||
}
|
||||
case domain.TelegramLoginSigningES256K:
|
||||
key, ok := ecdsaPrivateKey(raw)
|
||||
if !ok || key.Curve == nil || key.Curve.Params() == nil ||
|
||||
!strings.EqualFold(key.Curve.Params().Name, "secp256k1") || key.D == nil || key.X == nil || key.Y == nil {
|
||||
return errors.New("telegram login ES256K requires a secp256k1 ECDSA private key")
|
||||
}
|
||||
default:
|
||||
return domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rsaPrivateKey(raw any) (*rsa.PrivateKey, bool) {
|
||||
switch key := raw.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return key, key != nil
|
||||
case rsa.PrivateKey:
|
||||
return &key, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func ecdsaPrivateKey(raw any) (*ecdsa.PrivateKey, bool) {
|
||||
switch key := raw.(type) {
|
||||
case *ecdsa.PrivateKey:
|
||||
return key, key != nil
|
||||
case ecdsa.PrivateKey:
|
||||
return &key, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func telegramLoginJWA(algorithm domain.TelegramLoginSigningAlgorithm) (jwa.SignatureAlgorithm, error) {
|
||||
switch algorithm {
|
||||
case domain.TelegramLoginSigningRS256:
|
||||
return jwa.RS256(), nil
|
||||
case domain.TelegramLoginSigningES256:
|
||||
return jwa.ES256(), nil
|
||||
case domain.TelegramLoginSigningEdDSA:
|
||||
return jwa.EdDSA(), nil
|
||||
case domain.TelegramLoginSigningES256K:
|
||||
if telegramLoginES256KEnabled {
|
||||
return jwa.ES256K(), nil
|
||||
}
|
||||
return jwa.EmptySignatureAlgorithm(), errors.New("telegram login ES256K is disabled in this build")
|
||||
default:
|
||||
return jwa.EmptySignatureAlgorithm(), domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SigningKeyRing) SupportedAlgorithms() []string {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
ordered := make([]string, 0, len(r.active))
|
||||
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
|
||||
domain.TelegramLoginSigningRS256,
|
||||
domain.TelegramLoginSigningES256,
|
||||
domain.TelegramLoginSigningEdDSA,
|
||||
domain.TelegramLoginSigningES256K,
|
||||
} {
|
||||
if _, ok := r.active[algorithm]; ok {
|
||||
ordered = append(ordered, string(algorithm))
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
// ActiveAlgorithms returns the algorithms that can sign new tokens on this
|
||||
// instance. Callers use it to prevent durable client configuration from
|
||||
// selecting an algorithm without an active private key.
|
||||
func (r *SigningKeyRing) ActiveAlgorithms() []domain.TelegramLoginSigningAlgorithm {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
ordered := make([]domain.TelegramLoginSigningAlgorithm, 0, len(r.active))
|
||||
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
|
||||
domain.TelegramLoginSigningRS256,
|
||||
domain.TelegramLoginSigningES256,
|
||||
domain.TelegramLoginSigningEdDSA,
|
||||
domain.TelegramLoginSigningES256K,
|
||||
} {
|
||||
if _, ok := r.active[algorithm]; ok {
|
||||
ordered = append(ordered, algorithm)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (r *SigningKeyRing) JWKS() ([]byte, string, error) {
|
||||
if r == nil {
|
||||
return nil, "", errors.New("telegram login signing key ring is nil")
|
||||
}
|
||||
now := r.now().UTC()
|
||||
set := jwk.NewSet()
|
||||
for _, key := range r.keys {
|
||||
if !key.active && (key.publishUntil.IsZero() || !now.Before(key.publishUntil)) {
|
||||
continue
|
||||
}
|
||||
clone, err := key.public.Clone()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("clone telegram login public JWK: %w", err)
|
||||
}
|
||||
if err := set.AddKey(clone); err != nil {
|
||||
return nil, "", fmt.Errorf("add telegram login public JWK: %w", err)
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(set)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("marshal telegram login JWKS: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
return body, `"` + base64.RawURLEncoding.EncodeToString(sum[:]) + `"`, nil
|
||||
}
|
||||
|
||||
func (r *SigningKeyRing) sign(algorithm domain.TelegramLoginSigningAlgorithm, token jwt.Token) (string, error) {
|
||||
if r == nil || token == nil {
|
||||
return "", errors.New("telegram login ID token signer is unavailable")
|
||||
}
|
||||
key, ok := r.active[algorithm]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no active telegram login signing key for %s", algorithm)
|
||||
}
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(key.jwaAlgorithm, key.private))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign telegram login ID token with %s: %w", algorithm, err)
|
||||
}
|
||||
return string(signed), nil
|
||||
}
|
||||
|
||||
type IDTokenIssuerConfig struct {
|
||||
Issuer string
|
||||
TTL time.Duration
|
||||
Now func() time.Time
|
||||
AllowHTTP bool
|
||||
}
|
||||
|
||||
type IDTokenIssuer struct {
|
||||
issuer string
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
keys *SigningKeyRing
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) Issuer() string {
|
||||
if i == nil {
|
||||
return ""
|
||||
}
|
||||
return i.issuer
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) TTL() time.Duration {
|
||||
if i == nil {
|
||||
return 0
|
||||
}
|
||||
return i.ttl
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) SupportedAlgorithms() []string {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
return i.keys.SupportedAlgorithms()
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) JWKS() ([]byte, string, error) {
|
||||
if i == nil {
|
||||
return nil, "", errors.New("telegram login ID token issuer is nil")
|
||||
}
|
||||
return i.keys.JWKS()
|
||||
}
|
||||
|
||||
func NewIDTokenIssuer(keys *SigningKeyRing, cfg IDTokenIssuerConfig) (*IDTokenIssuer, error) {
|
||||
if keys == nil {
|
||||
return nil, errors.New("telegram login signing key ring is required")
|
||||
}
|
||||
issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowHTTP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram login ID token issuer: %w", err)
|
||||
}
|
||||
if cfg.TTL == 0 {
|
||||
cfg.TTL = defaultIDTokenTTL
|
||||
}
|
||||
if cfg.TTL < time.Minute || cfg.TTL > 24*time.Hour {
|
||||
return nil, errors.New("telegram login ID token TTL is outside the bounded range")
|
||||
}
|
||||
if cfg.Now == nil {
|
||||
cfg.Now = time.Now
|
||||
}
|
||||
return &IDTokenIssuer{issuer: issuer, ttl: cfg.TTL, now: cfg.Now, keys: keys}, nil
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) Issue(request domain.TelegramLoginRequest) (string, error) {
|
||||
if i == nil || request.Status != domain.TelegramLoginRequestApproved || request.AuthorizedUserID <= 0 ||
|
||||
request.ClientID == "" || request.ApprovedAt.IsZero() {
|
||||
return "", domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if err := domain.ValidateTelegramLoginScopes(request.Scopes, request.SigningAlgorithm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
identity := domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: request.AuthorizedUserID, Name: request.ProfileName, GivenName: request.GivenName,
|
||||
FamilyName: request.FamilyName, PreferredUsername: request.PreferredUsername,
|
||||
Picture: request.Picture, PhoneNumber: request.PhoneNumber,
|
||||
}
|
||||
identity, err := identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), request.PhoneShared)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := i.now().UTC()
|
||||
builder := jwt.NewBuilder().
|
||||
Issuer(i.issuer).
|
||||
Audience([]string{request.ClientID}).
|
||||
Subject(fmt.Sprintf("%d", identity.UserID)).
|
||||
IssuedAt(now).
|
||||
Expiration(now.Add(i.ttl))
|
||||
if request.Nonce != "" {
|
||||
builder.Claim("nonce", request.Nonce)
|
||||
}
|
||||
if request.Requests(domain.TelegramLoginScopeProfile) {
|
||||
builder.Claim("id", identity.UserID).
|
||||
Claim("name", identity.Name).
|
||||
Claim("given_name", identity.GivenName)
|
||||
if identity.FamilyName != "" {
|
||||
builder.Claim("family_name", identity.FamilyName)
|
||||
}
|
||||
if identity.PreferredUsername != "" {
|
||||
builder.Claim("preferred_username", identity.PreferredUsername)
|
||||
}
|
||||
if identity.Picture != "" {
|
||||
builder.Claim("picture", identity.Picture)
|
||||
}
|
||||
}
|
||||
if request.Requests(domain.TelegramLoginScopePhone) && request.PhoneShared {
|
||||
builder.Claim("phone_number", identity.PhoneNumber).
|
||||
Claim("phone_number_verified", true)
|
||||
}
|
||||
token, err := builder.Build()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build telegram login ID token: %w", err)
|
||||
}
|
||||
return i.keys.sign(request.SigningAlgorithm, token)
|
||||
}
|
||||
5
internal/app/telegramlogin/jose_es256k_disabled.go
Normal file
5
internal/app/telegramlogin/jose_es256k_disabled.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build !jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
const telegramLoginES256KEnabled = false
|
||||
17
internal/app/telegramlogin/jose_es256k_disabled_test.go
Normal file
17
internal/app/telegramlogin/jose_es256k_disabled_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//go:build !jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestES256KFailsClosedWithoutBuildTag(t *testing.T) {
|
||||
if _, err := NewSigningKeyRing([]SigningKeyMaterial{{
|
||||
Algorithm: domain.TelegramLoginSigningES256K, PrivateKey: struct{}{}, Active: true,
|
||||
}}, nil); err == nil {
|
||||
t.Fatal("ES256K configuration unexpectedly accepted without jwx_es256k")
|
||||
}
|
||||
}
|
||||
5
internal/app/telegramlogin/jose_es256k_enabled.go
Normal file
5
internal/app/telegramlogin/jose_es256k_enabled.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
const telegramLoginES256KEnabled = true
|
||||
57
internal/app/telegramlogin/jose_es256k_enabled_test.go
Normal file
57
internal/app/telegramlogin/jose_es256k_enabled_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//go:build jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestES256KIDTokenRoundTripWithBuildTag(t *testing.T) {
|
||||
raw, err := secp256k1.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
ring, err := NewSigningKeyRing([]SigningKeyMaterial{{
|
||||
Algorithm: domain.TelegramLoginSigningES256K,
|
||||
KeyID: "secp256k1-active",
|
||||
PrivateKey: raw.ToECDSA(),
|
||||
Active: true,
|
||||
}}, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{
|
||||
Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signed, err := issuer.Issue(domain.TelegramLoginRequest{
|
||||
ClientID: "9001", SigningAlgorithm: domain.TelegramLoginSigningES256K,
|
||||
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID},
|
||||
Status: domain.TelegramLoginRequestApproved, AuthorizedUserID: 42,
|
||||
ApprovedAt: now.Add(-time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, err := ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := jwk.Parse(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false)); err != nil {
|
||||
t.Fatalf("verify ES256K token: %v", err)
|
||||
}
|
||||
}
|
||||
203
internal/app/telegramlogin/jose_test.go
Normal file
203
internal/app/telegramlogin/jose_test.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func telegramLoginTestSigningKeys(t *testing.T, now *time.Time) *SigningKeyRing {
|
||||
t.Helper()
|
||||
oldRSA, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activeRSA, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
es256, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, ed25519Key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ring, err := NewSigningKeyRing([]SigningKeyMaterial{
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-old", PrivateKey: oldRSA, PublishUntil: now.Add(2 * time.Hour)},
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-active", PrivateKey: activeRSA, Active: true},
|
||||
{Algorithm: domain.TelegramLoginSigningES256, KeyID: "p256-active", PrivateKey: es256, Active: true},
|
||||
{Algorithm: domain.TelegramLoginSigningEdDSA, KeyID: "ed25519-active", PrivateKey: ed25519Key, Active: true},
|
||||
}, func() time.Time { return *now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ring
|
||||
}
|
||||
|
||||
func TestSigningKeyRingRotationAndAlgorithms(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
ring := telegramLoginTestSigningKeys(t, &now)
|
||||
if got := ring.SupportedAlgorithms(); len(got) != 3 || got[0] != "RS256" || got[1] != "ES256" || got[2] != "EdDSA" {
|
||||
t.Fatalf("SupportedAlgorithms = %#v", got)
|
||||
}
|
||||
body, etag, err := ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := jwk.Parse(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parse JWKS: %v", err)
|
||||
}
|
||||
if set.Len() != 4 || etag == "" {
|
||||
t.Fatalf("JWKS len=%d etag=%q body=%s", set.Len(), etag, body)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < set.Len(); i++ {
|
||||
key, _ := set.Key(i)
|
||||
if key.Has("d") || key.Has("p") || key.Has("q") {
|
||||
t.Fatalf("JWKS leaked private key material: %s", body)
|
||||
}
|
||||
}
|
||||
now = now.Add(3 * time.Hour)
|
||||
body, _, err = ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err = jwk.Parse(body)
|
||||
if err != nil || set.Len() != 3 {
|
||||
t.Fatalf("JWKS after retirement len=%d err=%v body=%s", set.Len(), err, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDTokenIssuerScopeProjectionAndVerification(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
ring := telegramLoginTestSigningKeys(t, &now)
|
||||
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{
|
||||
Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profileRequest := domain.TelegramLoginRequest{
|
||||
ClientID: "9001", SigningAlgorithm: domain.TelegramLoginSigningRS256,
|
||||
Scopes: []domain.TelegramLoginScope{
|
||||
domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopePhone,
|
||||
},
|
||||
Nonce: "request-nonce", Status: domain.TelegramLoginRequestApproved, AuthorizedUserID: 42,
|
||||
ProfileName: "Alice Example", GivenName: "Alice", FamilyName: "Example",
|
||||
PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42",
|
||||
PhoneNumber: "15551234567", PhoneShared: true, ApprovedAt: now.Add(-time.Minute),
|
||||
}
|
||||
signed, err := issuer.Issue(profileRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwksBody, _, err := ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := jwk.Parse(jwksBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false))
|
||||
if err != nil {
|
||||
t.Fatalf("verify signed token: %v", err)
|
||||
}
|
||||
issuerValue, _ := token.Issuer()
|
||||
subject, _ := token.Subject()
|
||||
audience, _ := token.Audience()
|
||||
if issuerValue != "https://oauth.telesrv.test" || subject != "42" || len(audience) != 1 || audience[0] != "9001" {
|
||||
t.Fatalf("standard claims iss=%q sub=%q aud=%#v", issuerValue, subject, audience)
|
||||
}
|
||||
var id float64
|
||||
var name, phone, nonce string
|
||||
var verified bool
|
||||
if err := token.Get("id", &id); err != nil || id != 42 {
|
||||
t.Fatalf("id claim=%v err=%v", id, err)
|
||||
}
|
||||
if err := token.Get("name", &name); err != nil || name != "Alice Example" {
|
||||
t.Fatalf("name claim=%q err=%v", name, err)
|
||||
}
|
||||
if err := token.Get("phone_number", &phone); err != nil || phone != "15551234567" {
|
||||
t.Fatalf("phone claim=%q err=%v", phone, err)
|
||||
}
|
||||
if err := token.Get("phone_number_verified", &verified); err != nil || !verified {
|
||||
t.Fatalf("phone verified=%v err=%v", verified, err)
|
||||
}
|
||||
if err := token.Get("nonce", &nonce); err != nil || nonce != "request-nonce" {
|
||||
t.Fatalf("nonce=%q err=%v", nonce, err)
|
||||
}
|
||||
|
||||
openidOnly := profileRequest
|
||||
openidOnly.SigningAlgorithm = domain.TelegramLoginSigningEdDSA
|
||||
openidOnly.Scopes = []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID}
|
||||
openidOnly.ProfileName = ""
|
||||
openidOnly.GivenName = ""
|
||||
openidOnly.FamilyName = ""
|
||||
openidOnly.PreferredUsername = ""
|
||||
openidOnly.Picture = ""
|
||||
openidOnly.PhoneNumber = ""
|
||||
openidOnly.PhoneShared = false
|
||||
signed, err = issuer.Issue(openidOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err = jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false))
|
||||
if err != nil {
|
||||
t.Fatalf("verify EdDSA token: %v", err)
|
||||
}
|
||||
if token.Has("id") || token.Has("name") || token.Has("phone_number") {
|
||||
t.Fatalf("openid-only token leaked optional claims: %#v", token.Keys())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDTokenIssuerAcceptsHTTPIPOnlyWhenEnabled(t *testing.T) {
|
||||
now := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC)
|
||||
ring := telegramLoginTestSigningKeys(t, &now)
|
||||
if _, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401"}); err == nil {
|
||||
t.Fatal("HTTP issuer was accepted while AllowHTTP was false")
|
||||
}
|
||||
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401", AllowHTTP: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if issuer.Issuer() != "http://192.0.2.25:2401" {
|
||||
t.Fatalf("issuer=%q", issuer.Issuer())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigningKeyRingRejectsWrongCurveAndDuplicateActiveKey(t *testing.T) {
|
||||
p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewSigningKeyRing([]SigningKeyMaterial{{
|
||||
Algorithm: domain.TelegramLoginSigningES256, PrivateKey: p384, Active: true,
|
||||
}}, nil); err == nil {
|
||||
t.Fatal("P-384 key unexpectedly accepted for ES256")
|
||||
}
|
||||
key1, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
key2, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if _, err := NewSigningKeyRing([]SigningKeyMaterial{
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, PrivateKey: key1, Active: true},
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, PrivateKey: key2, Active: true},
|
||||
}, nil); err == nil {
|
||||
t.Fatal("two active RS256 keys unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
186
internal/app/telegramlogin/keyfiles.go
Normal file
186
internal/app/telegramlogin/keyfiles.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTelegramLoginManifestBytes = 1 << 20
|
||||
maxTelegramLoginKeyBytes = 256 << 10
|
||||
)
|
||||
|
||||
type signingKeyManifest struct {
|
||||
Version int `json:"version"`
|
||||
Keys []signingKeyManifestEntry `json:"keys"`
|
||||
}
|
||||
|
||||
type signingKeyManifestEntry struct {
|
||||
Algorithm domain.TelegramLoginSigningAlgorithm `json:"algorithm"`
|
||||
KeyID string `json:"kid,omitempty"`
|
||||
PrivateKeyFile string `json:"private_key_file"`
|
||||
Active bool `json:"active"`
|
||||
PublishUntil string `json:"publish_until,omitempty"`
|
||||
}
|
||||
|
||||
// LoadSigningKeyRing reads a versioned manifest and private PEM/JWK files.
|
||||
// Relative key paths are resolved against the manifest directory. The caller
|
||||
// should atomically replace files and rebuild/swap the ring when rotating.
|
||||
func LoadSigningKeyRing(path string, now func() time.Time) (*SigningKeyRing, error) {
|
||||
var manifest signingKeyManifest
|
||||
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("load telegram login signing manifest: %w", err)
|
||||
}
|
||||
if manifest.Version != 1 || len(manifest.Keys) == 0 || len(manifest.Keys) > 32 {
|
||||
return nil, errors.New("telegram login signing manifest has invalid version or key count")
|
||||
}
|
||||
baseDir := filepath.Dir(path)
|
||||
materials := make([]SigningKeyMaterial, 0, len(manifest.Keys))
|
||||
for index, entry := range manifest.Keys {
|
||||
keyPath := strings.TrimSpace(entry.PrivateKeyFile)
|
||||
if !entry.Algorithm.Valid() || keyPath == "" {
|
||||
return nil, fmt.Errorf("telegram login signing manifest key %d is invalid", index)
|
||||
}
|
||||
if !filepath.IsAbs(keyPath) {
|
||||
keyPath = filepath.Join(baseDir, keyPath)
|
||||
}
|
||||
data, err := readBoundedFile(keyPath, maxTelegramLoginKeyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var parsed jwk.Key
|
||||
if len(bytes.TrimSpace(data)) > 0 && bytes.TrimSpace(data)[0] == '{' {
|
||||
parsed, err = jwk.ParseKey(data)
|
||||
} else {
|
||||
parsed, err = jwk.ParseKey(data, jwk.WithPEM(true))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var raw any
|
||||
if err := jwk.Export(parsed, &raw); err != nil {
|
||||
return nil, fmt.Errorf("export telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var publishUntil time.Time
|
||||
if entry.PublishUntil != "" {
|
||||
publishUntil, err = time.Parse(time.RFC3339, entry.PublishUntil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse telegram login signing key %d publish_until: %w", index, err)
|
||||
}
|
||||
}
|
||||
if entry.Active && !publishUntil.IsZero() {
|
||||
return nil, fmt.Errorf("active telegram login signing key %d must not set publish_until", index)
|
||||
}
|
||||
if !entry.Active && publishUntil.IsZero() {
|
||||
return nil, fmt.Errorf("retiring telegram login signing key %d requires publish_until", index)
|
||||
}
|
||||
materials = append(materials, SigningKeyMaterial{
|
||||
Algorithm: entry.Algorithm, KeyID: entry.KeyID, PrivateKey: raw,
|
||||
Active: entry.Active, PublishUntil: publishUntil,
|
||||
})
|
||||
}
|
||||
return NewSigningKeyRing(materials, now)
|
||||
}
|
||||
|
||||
type codeKeyManifest struct {
|
||||
Version int `json:"version"`
|
||||
Active string `json:"active"`
|
||||
Keys map[string]string `json:"keys"`
|
||||
}
|
||||
|
||||
func LoadCodeSealer(path string) (*CodeSealer, error) {
|
||||
var manifest codeKeyManifest
|
||||
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("load telegram login code-key manifest: %w", err)
|
||||
}
|
||||
if manifest.Version != 1 || manifest.Active == "" || len(manifest.Keys) == 0 || len(manifest.Keys) > 16 {
|
||||
return nil, errors.New("telegram login code-key manifest has invalid version or key count")
|
||||
}
|
||||
keys := make(map[string][]byte, len(manifest.Keys))
|
||||
for keyID, encoded := range manifest.Keys {
|
||||
if strings.TrimSpace(keyID) == "" || keyID != strings.TrimSpace(keyID) || len(keyID) > 128 {
|
||||
return nil, errors.New("telegram login code-key manifest has invalid key id")
|
||||
}
|
||||
raw, err := decodeBase64Key(encoded)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, fmt.Errorf("telegram login code-key %q must be 32 base64-encoded bytes", keyID)
|
||||
}
|
||||
keys[keyID] = raw
|
||||
}
|
||||
return NewCodeSealer(manifest.Active, keys)
|
||||
}
|
||||
|
||||
func LoadClientSecretPepper(path string) ([]byte, error) {
|
||||
data, err := readBoundedFile(path, 4096)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read telegram login client-secret pepper: %w", err)
|
||||
}
|
||||
raw, err := decodeBase64Key(strings.TrimSpace(string(data)))
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, errors.New("telegram login client-secret pepper must be 32 base64-encoded bytes")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func decodeBase64Key(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, encoding := range []*base64.Encoding{
|
||||
base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding,
|
||||
} {
|
||||
if raw, err := encoding.DecodeString(value); err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid base64")
|
||||
}
|
||||
|
||||
func readStrictJSONFile(path string, maxBytes int64, target any) error {
|
||||
data, err := readBoundedFile(path, maxBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("multiple JSON values")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readBoundedFile(path string, maxBytes int64) ([]byte, error) {
|
||||
if strings.TrimSpace(path) == "" || maxBytes <= 0 {
|
||||
return nil, errors.New("invalid file path or size bound")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() > maxBytes {
|
||||
return nil, errors.New("file is not regular or exceeds size bound")
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(file, maxBytes+1))
|
||||
}
|
||||
86
internal/app/telegramlogin/keyfiles_test.go
Normal file
86
internal/app/telegramlogin/keyfiles_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadSigningKeyRingAndSymmetricKeyFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rsaKey)})
|
||||
if err := os.WriteFile(filepath.Join(dir, "rsa.pem"), pemBytes, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := map[string]any{
|
||||
"version": 1,
|
||||
"keys": []map[string]any{{
|
||||
"algorithm": "RS256", "kid": "rsa-test", "private_key_file": "rsa.pem", "active": true,
|
||||
}},
|
||||
}
|
||||
manifestBytes, _ := json.Marshal(manifest)
|
||||
manifestPath := filepath.Join(dir, "signing.json")
|
||||
if err := os.WriteFile(manifestPath, manifestBytes, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ring, err := LoadSigningKeyRing(manifestPath, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ring.SupportedAlgorithms(); len(got) != 1 || got[0] != "RS256" {
|
||||
t.Fatalf("algorithms=%#v", got)
|
||||
}
|
||||
|
||||
codeKey := make([]byte, 32)
|
||||
pepper := make([]byte, 32)
|
||||
_, _ = rand.Read(codeKey)
|
||||
_, _ = rand.Read(pepper)
|
||||
codeManifest, _ := json.Marshal(map[string]any{
|
||||
"version": 1, "active": "2026-07", "keys": map[string]string{"2026-07": base64.RawURLEncoding.EncodeToString(codeKey)},
|
||||
})
|
||||
codePath := filepath.Join(dir, "code-keys.json")
|
||||
pepperPath := filepath.Join(dir, "pepper")
|
||||
if err := os.WriteFile(codePath, codeManifest, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pepperPath, []byte(base64.RawURLEncoding.EncodeToString(pepper)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealer, err := LoadCodeSealer(codePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, nonce, kid, err := sealer.Seal("code", []byte("aad"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opened, err := sealer.Open(sealed, nonce, kid, []byte("aad")); err != nil || opened != "code" {
|
||||
t.Fatalf("open=%q err=%v", opened, err)
|
||||
}
|
||||
loadedPepper, err := LoadClientSecretPepper(pepperPath)
|
||||
if err != nil || string(loadedPepper) != string(pepper) {
|
||||
t.Fatalf("pepper len=%d err=%v", len(loadedPepper), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSigningKeyRingRejectsUnknownManifestFieldAndUnboundedRetiringKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"keys":[],"unknown":true}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadSigningKeyRing(path, func() time.Time { return time.Now() }); err == nil {
|
||||
t.Fatal("unknown manifest field unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
80
internal/app/telegramlogin/native.go
Normal file
80
internal/app/telegramlogin/native.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var nativeApplicationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,254}$`)
|
||||
|
||||
func normalizeNativeApplicationID(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if !nativeApplicationIDPattern.MatchString(raw) || !strings.Contains(raw, ".") || strings.Contains(raw, "..") {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func normalizeNativeVerificationID(platform domain.TelegramLoginNativePlatform, raw string) (string, error) {
|
||||
raw = strings.ToUpper(strings.TrimSpace(raw))
|
||||
switch platform {
|
||||
case domain.TelegramLoginNativeIOS:
|
||||
if len(raw) != 10 || strings.IndexFunc(raw, func(r rune) bool {
|
||||
return !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9'))
|
||||
}) >= 0 {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
case domain.TelegramLoginNativeAndroid:
|
||||
raw = strings.ReplaceAll(raw, ":", "")
|
||||
if len(raw) != 64 || strings.IndexFunc(raw, func(r rune) bool {
|
||||
return !((r >= 'A' && r <= 'F') || (r >= '0' && r <= '9'))
|
||||
}) >= 0 {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
default:
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// NormalizeNativeCallbackURI accepts the exact HTTPS universal/app link or a
|
||||
// non-web custom scheme registered for a native application. Query and
|
||||
// fragment components are forbidden because OAuth response fields are
|
||||
// appended by the provider and must not collide with application input.
|
||||
func NormalizeNativeCallbackURI(raw string, allowHTTP bool) (string, error) {
|
||||
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !u.IsAbs() || u.Opaque != "" || u.User != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") {
|
||||
normalized, _, err := NormalizeRedirectURI(raw, allowHTTP)
|
||||
return normalized, err
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if !validAppScheme(scheme) || scheme == "tg" || scheme == "javascript" || scheme == "data" || scheme == "file" || u.Port() != "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
|
||||
if host == "" || strings.IndexFunc(host, func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '.')
|
||||
}) >= 0 {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u.Scheme, u.Host = scheme, host
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func normalizeNativeDisplayName(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || len(raw) > 128 || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
1379
internal/app/telegramlogin/service.go
Normal file
1379
internal/app/telegramlogin/service.go
Normal file
File diff suppressed because it is too large
Load diff
424
internal/app/telegramlogin/service_test.go
Normal file
424
internal/app/telegramlogin/service_test.go
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestServiceClientCreationAndSecretRotationAreSingleWinner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, loginStore := newTelegramLoginTestService(t, &now)
|
||||
|
||||
const contenders = 24
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
var created atomic.Int32
|
||||
var conflicts atomic.Int32
|
||||
for i := 0; i < contenders; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := service.CreateClient(ctx, 9010, domain.TelegramLoginSigningRS256)
|
||||
switch {
|
||||
case err == nil:
|
||||
created.Add(1)
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
|
||||
conflicts.Add(1)
|
||||
default:
|
||||
t.Errorf("CreateClient: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if created.Load() != 1 || conflicts.Load() != contenders-1 {
|
||||
t.Fatalf("create winners=%d conflicts=%d", created.Load(), conflicts.Load())
|
||||
}
|
||||
|
||||
client, found, err := loginStore.GetTelegramLoginClientByBot(ctx, 9010)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetTelegramLoginClientByBot: found=%v err=%v", found, err)
|
||||
}
|
||||
start = make(chan struct{})
|
||||
created.Store(0)
|
||||
conflicts.Store(0)
|
||||
for i := 0; i < contenders; i++ {
|
||||
wg.Add(1)
|
||||
go func(seed byte) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
hash := make([]byte, 32)
|
||||
hash[0] = seed
|
||||
_, err := loginStore.RotateTelegramLoginClientSecret(ctx, client.BotUserID, client.SecretVersion, hash, now.Add(time.Second))
|
||||
switch {
|
||||
case err == nil:
|
||||
created.Add(1)
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
|
||||
conflicts.Add(1)
|
||||
default:
|
||||
t.Errorf("RotateTelegramLoginClientSecret: %v", err)
|
||||
}
|
||||
}(byte(i + 1))
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if created.Load() != 1 || conflicts.Load() != contenders-1 {
|
||||
t.Fatalf("rotate winners=%d conflicts=%d", created.Load(), conflicts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func newTelegramLoginTestService(t *testing.T, now *time.Time) (*Service, *memory.TelegramLoginStore) {
|
||||
return newTelegramLoginTestServiceWithConfig(t, now, nil, "")
|
||||
}
|
||||
|
||||
func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, algorithms []domain.TelegramLoginSigningAlgorithm) (*Service, *memory.TelegramLoginStore) {
|
||||
return newTelegramLoginTestServiceWithConfig(t, now, algorithms, "")
|
||||
}
|
||||
|
||||
func newTelegramLoginTestServiceWithAppLinkBase(t *testing.T, now *time.Time, appLinkBase string) (*Service, *memory.TelegramLoginStore) {
|
||||
return newTelegramLoginTestServiceWithConfig(t, now, nil, appLinkBase)
|
||||
}
|
||||
|
||||
func newTelegramLoginTestServiceWithConfig(t *testing.T, now *time.Time, algorithms []domain.TelegramLoginSigningAlgorithm, appLinkBase string) (*Service, *memory.TelegramLoginStore) {
|
||||
t.Helper()
|
||||
key := make([]byte, 32)
|
||||
key[0] = 7
|
||||
sealer, err := NewCodeSealer("test", map[string][]byte{"test": key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loginStore := memory.NewTelegramLoginStore(nil)
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 9
|
||||
service, err := NewService(loginStore, sealer, Config{
|
||||
Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv", AppLinkBase: appLinkBase,
|
||||
AllowHTTP: true, ClientSecretPepper: pepper,
|
||||
SupportedSigningAlgorithms: algorithms,
|
||||
Now: func() time.Time { return *now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service, loginStore
|
||||
}
|
||||
|
||||
func TestServiceAcceptsOfficialClientCanonicalOAuthDeepLinks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestServiceWithAppLinkBase(t, &now, "owpg://tenant.example.test")
|
||||
credentials, err := service.CreateClient(ctx, 9030, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const redirectURI = "https://rp.example/callback"
|
||||
if _, err := service.AddAllowedURL(ctx, 9030, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
challenge, err := PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
|
||||
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(created.DeepLink)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := parsed.Query().Get("token")
|
||||
if got, want := parsed.Scheme+"://"+parsed.Host+parsed.Path, "owpg://tenant.example.test/oauth"; got != want {
|
||||
t.Fatalf("generated deep link root = %q, want %q", got, want)
|
||||
}
|
||||
valid := []string{
|
||||
created.DeepLink,
|
||||
"telesrv://oauth?token=" + url.QueryEscape(token),
|
||||
"telesrv://resolve?domain=oauth&startapp=" + url.QueryEscape(token),
|
||||
"tg://oauth?token=" + url.QueryEscape(token),
|
||||
"tg://resolve?domain=oauth&startapp=" + url.QueryEscape(token),
|
||||
"https://t.me/oauth?startapp=" + url.QueryEscape(token),
|
||||
}
|
||||
for _, deepLink := range valid {
|
||||
request, err := service.RequestByDeepLink(ctx, deepLink)
|
||||
if err != nil || request.ID != created.Request.ID {
|
||||
t.Fatalf("RequestByDeepLink(%q) request=%#v err=%v", deepLink, request, err)
|
||||
}
|
||||
}
|
||||
invalid := []string{
|
||||
"telegram://oauth?token=" + url.QueryEscape(token),
|
||||
"owpg://other.example.test/oauth?token=" + url.QueryEscape(token),
|
||||
"owpg://tenant.example.test/resolve?domain=oauth&startapp=" + url.QueryEscape(token),
|
||||
"owpg://tenant.example.test/oauth/extra?token=" + url.QueryEscape(token),
|
||||
"tg://oauth/path?token=" + url.QueryEscape(token),
|
||||
"tg://oauth?token=" + url.QueryEscape(token) + "&token=other",
|
||||
"tg://resolve?domain=oauth&domain=other&startapp=" + url.QueryEscape(token),
|
||||
"tg://resolve?domain=oauth&startapp=" + url.QueryEscape(token) + "&startapp=other",
|
||||
"tg://oauth?token=" + url.QueryEscape(token) + "#fragment",
|
||||
}
|
||||
for _, deepLink := range invalid {
|
||||
if _, err := service.RequestByDeepLink(ctx, deepLink); !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
|
||||
t.Fatalf("RequestByDeepLink(%q) error=%v, want URL invalid", deepLink, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsSigningAlgorithmsWithoutActiveKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, loginStore := newTelegramLoginTestServiceWithAlgorithms(t, &now, []domain.TelegramLoginSigningAlgorithm{
|
||||
domain.TelegramLoginSigningES256,
|
||||
})
|
||||
if _, err := service.CreateClient(ctx, 9020, domain.TelegramLoginSigningRS256); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
t.Fatalf("CreateClient unsupported algorithm error=%v", err)
|
||||
}
|
||||
credentials, created, err := service.EnsureClient(ctx, 9020)
|
||||
if err != nil || !created || credentials.Client.SigningAlgorithm != domain.TelegramLoginSigningES256 {
|
||||
t.Fatalf("EnsureClient credentials=%#v created=%v err=%v", credentials, created, err)
|
||||
}
|
||||
if _, err := service.SetClientSigningAlgorithm(ctx, 9020, domain.TelegramLoginSigningEdDSA); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
t.Fatalf("SetClientSigningAlgorithm unsupported error=%v", err)
|
||||
}
|
||||
|
||||
// Simulate configuration drift from a previous deployment. Authorization
|
||||
// must fail before a request is persisted instead of failing after consent.
|
||||
if _, err := loginStore.SetTelegramLoginClientSigningAlgorithm(ctx, 9020, domain.TelegramLoginSigningRS256, now.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.SetClientEnabled(ctx, 9020, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.SetClientEnabled(ctx, 9020, true); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
t.Fatalf("SetClientEnabled unavailable algorithm error=%v", err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(ctx, 9020, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: "https://rp.example/", ResponseType: "post_message",
|
||||
Scope: "openid profile",
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginClientDisabled) {
|
||||
t.Fatalf("CreateAuthorization unavailable algorithm error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAuthorizationCodeFlowAndRevocation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestService(t, &now)
|
||||
credentials, err := service.CreateClient(ctx, 9001, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateClient: %v", err)
|
||||
}
|
||||
const redirectURI = "https://rp.example/callback"
|
||||
if _, err := service.AddAllowedURL(ctx, 9001, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatalf("AddAllowedURL redirect: %v", err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(ctx, 9001, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil {
|
||||
t.Fatalf("AddAllowedURL origin: %v", err)
|
||||
}
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge, _ := PKCEChallenge(verifier)
|
||||
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI,
|
||||
ResponseType: "code", Scope: "openid profile phone telegram:bot_access",
|
||||
State: "opaque-state", Nonce: "nonce", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
Browser: "Firefox", Platform: "Windows", IP: "192.0.2.10", Region: "Test Region",
|
||||
IncludeMatchCodes: true, MatchCodesFirst: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuthorization: %v", err)
|
||||
}
|
||||
if created.DeepLink == "" || created.Request.ID == 0 || len(created.Request.MatchCodes) != 5 {
|
||||
t.Fatalf("created authorization = %#v", created)
|
||||
}
|
||||
if !strings.HasPrefix(created.DeepLink, "telesrv://oauth?token=") {
|
||||
t.Fatalf("default deep link = %q, want legacy telesrv:// OAuth form", created.DeepLink)
|
||||
}
|
||||
if _, err := service.CheckMatchCode(ctx, created.DeepLink, created.Request.MatchCodes[0]); err == nil && created.Request.MatchCodes[0] != created.Request.MatchCode {
|
||||
t.Fatal("wrong match code unexpectedly accepted")
|
||||
}
|
||||
if ok, err := service.CheckMatchCode(ctx, created.DeepLink, created.Request.MatchCode); err != nil || !ok {
|
||||
t.Fatalf("CheckMatchCode correct = %v,%v", ok, err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
identity := domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example",
|
||||
PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42",
|
||||
}
|
||||
approved, web, err := service.Approve(ctx, created.DeepLink, identity, true, false, created.Request.MatchCode)
|
||||
if err != nil {
|
||||
t.Fatalf("Approve: %v", err)
|
||||
}
|
||||
if approved.Status != domain.TelegramLoginRequestApproved || web.PhoneShared || !web.BotAccessGranted {
|
||||
t.Fatalf("approved=%#v web=%#v", approved, web)
|
||||
}
|
||||
if approved.ProfileName != "Alice Example" || approved.PhoneNumber != "" {
|
||||
t.Fatalf("identity snapshot = %#v", approved)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
finalized, err := service.FinalizeByBrowserToken(ctx, created.BrowserToken)
|
||||
if err != nil {
|
||||
t.Fatalf("FinalizeByBrowserToken: %v", err)
|
||||
}
|
||||
redirect, err := url.Parse(finalized.RedirectURL)
|
||||
if err != nil || redirect.Query().Get("code") != finalized.Code || redirect.Query().Get("state") != "opaque-state" {
|
||||
t.Fatalf("final redirect = %q,%v", finalized.RedirectURL, err)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier + "x",
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
|
||||
t.Fatalf("exchange wrong verifier error = %v, want code invalid", err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
exchanged, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeAuthorizationCode: %v", err)
|
||||
}
|
||||
if exchanged.Request.AuthorizedUserID != 42 || exchanged.WebAuthorization.Hash != web.Hash {
|
||||
t.Fatalf("exchanged = %#v", exchanged)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginCodeConsumed) {
|
||||
t.Fatalf("replay exchange error = %v, want consumed", err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 42, web.Hash); err != nil {
|
||||
t.Fatalf("RevokeWebAuthorization: %v", err)
|
||||
}
|
||||
if list, err := service.ListWebAuthorizations(ctx, 42); err != nil || len(list) != 0 {
|
||||
t.Fatalf("ListWebAuthorizations after revoke = %#v,%v", list, err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 42, web.Hash); !errors.Is(err, domain.ErrTelegramLoginWebAuthHashInvalid) {
|
||||
t.Fatalf("second revoke error = %v, want hash invalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizationRetryRechecksLiveAuthorization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestService(t, &now)
|
||||
credentials, err := service.CreateClient(ctx, 9010, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const redirectURI = "https://retry.example/callback"
|
||||
const origin = "https://retry.example"
|
||||
if _, err := service.AddAllowedURL(ctx, 9010, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(ctx, 9010, domain.TelegramLoginAllowedWebOrigin, origin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
challenge, err := PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codeRequest, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
|
||||
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, codeWeb, err := service.Approve(ctx, codeRequest.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 51}, false, false, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeByBrowserToken(ctx, codeRequest.BrowserToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 51, codeWeb.Hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeByBrowserToken(ctx, codeRequest.BrowserToken); !errors.Is(err, domain.ErrTelegramLoginRequestConflict) {
|
||||
t.Fatalf("authorization-code retry after revoke error = %v, want conflict", err)
|
||||
}
|
||||
|
||||
miniRequest, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: origin + "/", ResponseType: "post_message", Scope: "openid",
|
||||
Origin: origin, InAppOrigin: origin, Source: domain.TelegramLoginRequestMiniApp,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, miniWeb, err := service.Approve(ctx, miniRequest.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 52}, false, false, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeInAppRedirectByDeepLink(ctx, miniRequest.DeepLink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 52, miniWeb.Hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeInAppRedirectByDeepLink(ctx, miniRequest.DeepLink); !errors.Is(err, domain.ErrTelegramLoginRequestConflict) {
|
||||
t.Fatalf("Mini App token retry after revoke error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSecretRotationClosesExchangeTOCTOU(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestService(t, &now)
|
||||
oldCredentials, err := service.CreateClient(ctx, 9002, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const redirectURI = "https://rotate.example/callback"
|
||||
if _, err := service.AddAllowedURL(ctx, 9002, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge, _ := PKCEChallenge(verifier)
|
||||
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: oldCredentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
|
||||
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
if _, _, err := service.Approve(ctx, created.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 43}, false, false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
finalized, err := service.FinalizeByBrowserToken(ctx, created.BrowserToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newCredentials, err := service.RotateClientSecret(ctx, 9002)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: oldCredentials.Client.ClientID, ClientSecret: oldCredentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginSecretInvalid) {
|
||||
t.Fatalf("old secret exchange error = %v", err)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: newCredentials.Client.ClientID, ClientSecret: newCredentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
}); err != nil {
|
||||
t.Fatalf("new secret exchange: %v", err)
|
||||
}
|
||||
}
|
||||
133
internal/app/telegramlogin/url.go
Normal file
133
internal/app/telegramlogin/url.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const maxTelegramLoginURLLength = 4096
|
||||
|
||||
func NormalizeRedirectURI(raw string, allowHTTP bool) (normalized, domainName string, err error) {
|
||||
u, err := parseWebURL(raw, allowHTTP)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return "", "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
query := u.Query()
|
||||
for _, reserved := range []string{"code", "state", "error", "error_description"} {
|
||||
if _, exists := query[reserved]; exists {
|
||||
return "", "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
}
|
||||
if u.Path == "" {
|
||||
u.Path = "/"
|
||||
}
|
||||
return u.String(), u.Hostname(), nil
|
||||
}
|
||||
|
||||
func NormalizeWebOrigin(raw string, allowHTTP bool) (string, error) {
|
||||
u, err := parseWebURL(raw, allowHTTP)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u.Path = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func parseWebURL(raw string, allowHTTP bool) (*url.URL, error) {
|
||||
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !u.IsAbs() || u.Opaque != "" || u.User != nil || u.Host == "" {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
|
||||
if host == "" {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if ip := net.ParseIP(host); ip == nil {
|
||||
host, err = idna.Lookup.ToASCII(host)
|
||||
if err != nil || host == "" {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
}
|
||||
port := u.Port()
|
||||
if port != "" {
|
||||
n, err := strconv.Atoi(port)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
if port == "443" {
|
||||
port = ""
|
||||
}
|
||||
case "http":
|
||||
if !allowHTTP {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if port == "80" {
|
||||
port = ""
|
||||
}
|
||||
default:
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if port == "" {
|
||||
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
|
||||
u.Host = "[" + host + "]"
|
||||
} else {
|
||||
u.Host = host
|
||||
}
|
||||
} else {
|
||||
u.Host = net.JoinHostPort(host, port)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func AppendAuthorizationResult(redirectURI, code, state string) (string, error) {
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil || !u.IsAbs() || code == "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("code", code)
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func AppendAuthorizationError(redirectURI, errorCode, state string) (string, error) {
|
||||
switch errorCode {
|
||||
case "access_denied", "temporarily_unavailable", "server_error", "invalid_request", "invalid_scope", "unsupported_response_type":
|
||||
default:
|
||||
return "", domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil || !u.IsAbs() {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("error", errorCode)
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
116
internal/app/telegramlogin/url_test.go
Normal file
116
internal/app/telegramlogin/url_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestNormalizeRedirectURIIsExactAndRejectsOpenRedirectShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
allowHTTP bool
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{name: "https canonical", raw: "https://EXAMPLE.com:443/callback?tenant=one", want: "https://example.com/callback?tenant=one", valid: true},
|
||||
{name: "idna", raw: "https://例子.测试/callback", want: "https://xn--fsqu00a.xn--0zwm56d/callback", valid: true},
|
||||
{name: "http hostname enabled", raw: "http://example.com:8080/callback", allowHTTP: true, want: "http://example.com:8080/callback", valid: true},
|
||||
{name: "http ipv4 enabled", raw: "http://192.0.2.25:3000/callback", allowHTTP: true, want: "http://192.0.2.25:3000/callback", valid: true},
|
||||
{name: "http disabled", raw: "http://example.com/callback"},
|
||||
{name: "userinfo", raw: "https://user@example.com/callback"},
|
||||
{name: "fragment", raw: "https://example.com/callback#token"},
|
||||
{name: "reserved code", raw: "https://example.com/callback?code=attacker"},
|
||||
{name: "leading whitespace", raw: " https://example.com/callback"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, _, err := NormalizeRedirectURI(test.raw, test.allowHTTP)
|
||||
if test.valid {
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("NormalizeRedirectURI() = %q,%v, want %q,nil", got, err, test.want)
|
||||
}
|
||||
} else if !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
|
||||
t.Fatalf("NormalizeRedirectURI() error = %v, want URL invalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendAuthorizationErrorPreservesState(t *testing.T) {
|
||||
got, err := AppendAuthorizationError("https://example.com/callback?tenant=one", "access_denied", "opaque")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _ := url.Parse(got)
|
||||
if u.Query().Get("tenant") != "one" || u.Query().Get("error") != "access_denied" || u.Query().Get("state") != "opaque" {
|
||||
t.Fatalf("error redirect = %q", got)
|
||||
}
|
||||
if _, err := AppendAuthorizationError("https://example.com/callback", "invalid_client", ""); err == nil {
|
||||
t.Fatal("unsafe authorization error unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWebOriginRejectsPathAndQuery(t *testing.T) {
|
||||
if got, err := NormalizeWebOrigin("https://Example.com/", false); err != nil || got != "https://example.com" {
|
||||
t.Fatalf("NormalizeWebOrigin = %q,%v", got, err)
|
||||
}
|
||||
for _, raw := range []string{"https://example.com/path", "https://example.com/?x=1", "https://example.com/#x"} {
|
||||
if _, err := NormalizeWebOrigin(raw, false); !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
|
||||
t.Fatalf("NormalizeWebOrigin(%q) error = %v, want URL invalid", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHTTPIPv6PreservesURLBrackets(t *testing.T) {
|
||||
origin, err := NormalizeWebOrigin("http://[2001:db8::25]:80/", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if origin != "http://[2001:db8::25]" {
|
||||
t.Fatalf("origin=%q", origin)
|
||||
}
|
||||
redirect, domainName, err := NormalizeRedirectURI("http://[2001:db8::26]:3000/callback", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redirect != "http://[2001:db8::26]:3000/callback" || domainName != "2001:db8::26" {
|
||||
t.Fatalf("redirect=%q domain=%q", redirect, domainName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPKCERFC7636Vector(t *testing.T) {
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
const want = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
got, err := PKCEChallenge(verifier)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("PKCEChallenge = %q,%v, want %q,nil", got, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeSealerUsesAADAndRetiringKeys(t *testing.T) {
|
||||
oldKey := make([]byte, 32)
|
||||
newKey := make([]byte, 32)
|
||||
oldKey[0], newKey[0] = 1, 2
|
||||
old, err := NewCodeSealer("old", map[string][]byte{"old": oldKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, nonce, keyID, err := old.Seal("authorization-code", []byte("request-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotated, err := NewCodeSealer("new", map[string][]byte{"old": oldKey, "new": newKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := rotated.Open(sealed, nonce, keyID, []byte("request-1")); err != nil || got != "authorization-code" {
|
||||
t.Fatalf("Open after rotation = %q,%v", got, err)
|
||||
}
|
||||
if _, err := rotated.Open(sealed, nonce, keyID, []byte("request-2")); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
|
||||
t.Fatalf("Open with wrong AAD error = %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -451,6 +451,12 @@ func cloneCachedUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.ContactNoteEntities != nil {
|
||||
in.ContactNoteEntities = append([]domain.MessageEntity(nil), in.ContactNoteEntities...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,7 +116,13 @@ func (s *countingContactStore) SetPersonalPhoto(ctx context.Context, userID, con
|
|||
func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice", Phone: "111"}); err != nil {
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{
|
||||
ContactUserID: 2,
|
||||
FirstName: "Alice",
|
||||
Phone: "111",
|
||||
Note: "private note",
|
||||
NoteEntities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 7}},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
counting := &countingContactStore{ContactStore: base}
|
||||
|
|
@ -126,15 +132,16 @@ func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("get many first: %v", err)
|
||||
}
|
||||
if first[2].FirstName != "Alice" {
|
||||
t.Fatalf("first contact = %+v, want Alice", first[2])
|
||||
if first[2].FirstName != "Alice" || first[2].Note != "private note" || len(first[2].NoteEntities) != 1 {
|
||||
t.Fatalf("first contact = %+v, want Alice with private note", first[2])
|
||||
}
|
||||
first[2].NoteEntities[0].Length = 99
|
||||
second, err := cached.GetMany(ctx, 1, []int64{2, 3})
|
||||
if err != nil {
|
||||
t.Fatalf("get many second: %v", err)
|
||||
}
|
||||
if second[2].FirstName != "Alice" {
|
||||
t.Fatalf("second contact = %+v, want Alice", second[2])
|
||||
if second[2].FirstName != "Alice" || second[2].Note != "private note" || len(second[2].NoteEntities) != 1 || second[2].NoteEntities[0].Length != 7 {
|
||||
t.Fatalf("second contact = %+v, want isolated cached Alice note", second[2])
|
||||
}
|
||||
if counting.listCalls != 1 {
|
||||
t.Fatalf("ListByUser calls = %d, want 1 account snapshot load", counting.listCalls)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ type PrivacyEvaluator interface {
|
|||
CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error)
|
||||
}
|
||||
|
||||
// AccountFreezeProvider returns durable account freeze facts for a bounded
|
||||
// batch. The projector only exposes them to viewers other than the frozen user.
|
||||
type AccountFreezeProvider interface {
|
||||
AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error)
|
||||
}
|
||||
|
||||
// BatchPrivacyEvaluator 批量评估多 owner 对单 viewer 的可见性,消除 projectBatch / fan-out
|
||||
// 投影里 per-user 3×CanSee 的 N+1。可选:实现了它的 evaluator(privacy.Service)会被
|
||||
// projectBatch 优先用批量预取,否则回退逐 CanSee。结果必须与逐 CanSee 字节等价。
|
||||
|
|
@ -52,6 +58,7 @@ type Projector struct {
|
|||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
privacy PrivacyEvaluator
|
||||
freezes AccountFreezeProvider
|
||||
}
|
||||
|
||||
// Option configures a Projector.
|
||||
|
|
@ -72,6 +79,11 @@ func WithPrivacyEvaluator(privacy PrivacyEvaluator) Option {
|
|||
return func(p *Projector) { p.privacy = privacy }
|
||||
}
|
||||
|
||||
// WithAccountFreezeProvider enables viewer-scoped frozen-account visibility.
|
||||
func WithAccountFreezeProvider(provider AccountFreezeProvider) Option {
|
||||
return func(p *Projector) { p.freezes = provider }
|
||||
}
|
||||
|
||||
// New creates a user projector.
|
||||
func New(opts ...Option) *Projector {
|
||||
p := &Projector{}
|
||||
|
|
@ -87,7 +99,7 @@ func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []d
|
|||
if p == nil {
|
||||
return users, nil
|
||||
}
|
||||
return projectBatch(ctx, p.contacts, p.photos, p.privacy, viewerUserID, users)
|
||||
return projectBatch(ctx, p.contacts, p.photos, p.privacy, p.freezes, viewerUserID, users)
|
||||
}
|
||||
|
||||
// One applies ForViewer to a single user.
|
||||
|
|
@ -136,6 +148,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
fallbackRefs map[int64]domain.ProfilePhotoRef
|
||||
contactsByViewer map[int64]map[int64]domain.Contact
|
||||
matrix map[int64]map[int64]map[domain.PrivacyKey]bool
|
||||
freezes map[int64]domain.AccountFreeze
|
||||
)
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
// 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用;personal photo v1 跳过(见 doc)。
|
||||
|
|
@ -159,6 +172,13 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
return err
|
||||
})
|
||||
}
|
||||
if p.freezes != nil && len(ids) > 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
freezes, err = p.freezes.AccountFreezes(gctx, ids)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -194,6 +214,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
return nil, perr
|
||||
}
|
||||
}
|
||||
pj = applyAccountFreezeProjection(pj, viewer, freezes[u.ID])
|
||||
cache[u.ID] = pj
|
||||
projected[i] = pj
|
||||
}
|
||||
|
|
@ -260,6 +281,10 @@ func cloneUsers(users []domain.User) []domain.User {
|
|||
}
|
||||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
for i := range out {
|
||||
out[i].ContactNoteEntities = append([]domain.MessageEntity(nil), out[i].ContactNoteEntities...)
|
||||
out[i].RestrictionReasons = append([]domain.UserRestrictionReason(nil), out[i].RestrictionReasons...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -354,7 +379,7 @@ func One(ctx context.Context, contacts store.ContactStore, viewerUserID int64, u
|
|||
return projected[0], nil
|
||||
}
|
||||
|
||||
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, freezesProvider AccountFreezeProvider, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
if len(users) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
|
|
@ -368,6 +393,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
personalRefs = map[int64]domain.ProfilePhotoRef{}
|
||||
contactsByID map[int64]domain.Contact
|
||||
visibility map[int64]map[domain.PrivacyKey]bool
|
||||
freezes map[int64]domain.AccountFreeze
|
||||
)
|
||||
// 这些预取查询互不依赖(头像 profile/fallback、联系人 GetMany/PersonalPhotos、privacy 可见性),
|
||||
// 并发执行把 ~6 次串行 round-trip 收敛成一波;每个 goroutine 只写自己那一个变量,组装循环在
|
||||
|
|
@ -430,6 +456,16 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
visibility = v
|
||||
return nil
|
||||
})
|
||||
if freezesProvider != nil && len(ids) > 0 {
|
||||
g.Go(func() error {
|
||||
m, err := freezesProvider.AccountFreezes(gctx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
freezes = m
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -459,12 +495,23 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
projected = applyAccountFreezeProjection(projected, viewerUserID, freezes[u.ID])
|
||||
cache[u.ID] = projected
|
||||
out[i] = projected
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyAccountFreezeProjection(user domain.User, viewerUserID int64, freeze domain.AccountFreeze) domain.User {
|
||||
// Base users and self users must never retain a viewer-scoped restriction.
|
||||
user.RestrictionReasons = nil
|
||||
if user.Deleted || viewerUserID == 0 || user.ID == 0 || user.ID == viewerUserID || !freeze.Frozen {
|
||||
return user
|
||||
}
|
||||
user.RestrictionReasons = domain.AccountFrozenRestrictionReasons()
|
||||
return user
|
||||
}
|
||||
|
||||
func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) (map[int64]map[domain.PrivacyKey]bool, error) {
|
||||
if privacy == nil || viewerUserID == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -499,30 +546,7 @@ func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID i
|
|||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found {
|
||||
user.Phone = ""
|
||||
user.Contact = false
|
||||
user.Mutual = false
|
||||
user.CloseFriend = false
|
||||
return user, nil
|
||||
}
|
||||
projected := user
|
||||
projected.Contact = true
|
||||
projected.Mutual = contact.Mutual || contact.User.Mutual
|
||||
projected.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
|
||||
if contact.User.Phone != "" {
|
||||
projected.Phone = contact.User.Phone
|
||||
} else {
|
||||
projected.Phone = contact.Phone
|
||||
}
|
||||
if contact.User.FirstName != "" || contact.User.LastName != "" {
|
||||
projected.FirstName = contact.User.FirstName
|
||||
projected.LastName = contact.User.LastName
|
||||
} else if contact.FirstName != "" || contact.LastName != "" {
|
||||
projected.FirstName = contact.FirstName
|
||||
projected.LastName = contact.LastName
|
||||
}
|
||||
return projected, nil
|
||||
return applyContactProjection(user, contact, found), nil
|
||||
}
|
||||
|
||||
func uniqueUserIDs(users []domain.User) []int64 {
|
||||
|
|
@ -575,11 +599,15 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
user.Contact = false
|
||||
user.Mutual = false
|
||||
user.CloseFriend = false
|
||||
user.ContactNote = ""
|
||||
user.ContactNoteEntities = nil
|
||||
return user
|
||||
}
|
||||
user.Contact = true
|
||||
user.Mutual = contact.Mutual || contact.User.Mutual
|
||||
user.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
|
||||
user.ContactNote = contact.Note
|
||||
user.ContactNoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
|
||||
if contact.User.Phone != "" {
|
||||
user.Phone = contact.User.Phone
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
|
|||
Phone: "1111",
|
||||
FirstName: "Alice",
|
||||
LastName: "Contact",
|
||||
Note: "private note",
|
||||
NoteEntities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 7}},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
|
|
@ -47,12 +49,15 @@ func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
|
|||
if friend.FirstName != "Alice" || friend.LastName != "Contact" || friend.Phone != "1111" || !friend.Contact {
|
||||
t.Fatalf("friend projection = %+v, want contact name/phone", friend)
|
||||
}
|
||||
if friend.ContactNote != "private note" || len(friend.ContactNoteEntities) != 1 || friend.ContactNoteEntities[0].Type != domain.MessageEntityBold {
|
||||
t.Fatalf("friend contact note = %q %+v, want owner-scoped note", friend.ContactNote, friend.ContactNoteEntities)
|
||||
}
|
||||
if friend.PhotoID != 9001 || friend.PhotoDCID != 2 || string(friend.PhotoStripped) != string([]byte{1, 2}) {
|
||||
t.Fatalf("friend photo = id %d dc %d stripped %v, want 9001/2/[1 2]", friend.PhotoID, friend.PhotoDCID, friend.PhotoStripped)
|
||||
}
|
||||
stranger := projectionUser(t, users, strangerID)
|
||||
if stranger.Phone != "" || stranger.Contact {
|
||||
t.Fatalf("stranger projection = %+v, want hidden phone and non-contact", stranger)
|
||||
if stranger.Phone != "" || stranger.Contact || stranger.ContactNote != "" || len(stranger.ContactNoteEntities) != 0 {
|
||||
t.Fatalf("stranger projection = %+v, want hidden phone and no contact note", stranger)
|
||||
}
|
||||
if stranger.PhotoID != 9002 || stranger.PhotoDCID != 3 {
|
||||
t.Fatalf("stranger photo = id %d dc %d, want 9002/3", stranger.PhotoID, stranger.PhotoDCID)
|
||||
|
|
@ -114,6 +119,64 @@ func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
frozenUserID = int64(4001)
|
||||
otherViewer = int64(4002)
|
||||
)
|
||||
freezes := &fakeAccountFreezes{items: map[int64]domain.AccountFreeze{
|
||||
frozenUserID: {UserID: frozenUserID, Frozen: true, Version: 3},
|
||||
}}
|
||||
projector := New(WithAccountFreezeProvider(freezes))
|
||||
base := []domain.User{{
|
||||
ID: frozenUserID,
|
||||
FirstName: "Frozen",
|
||||
// Viewer-scoped fields must never be trusted from a reused base object.
|
||||
RestrictionReasons: []domain.UserRestrictionReason{{Platform: "all", Reason: "stale", Text: "stale"}},
|
||||
}}
|
||||
|
||||
otherView, err := projector.ForViewer(ctx, otherViewer, base)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer(other): %v", err)
|
||||
}
|
||||
got := projectionUser(t, otherView, frozenUserID)
|
||||
if !reflect.DeepEqual(got.RestrictionReasons, domain.AccountFrozenRestrictionReasons()) {
|
||||
t.Fatalf("other-view restriction = %+v, want frozen restriction", got.RestrictionReasons)
|
||||
}
|
||||
if base[0].RestrictionReasons[0].Reason != "stale" {
|
||||
t.Fatalf("projection mutated base user: %+v", base[0])
|
||||
}
|
||||
|
||||
selfView, err := projector.ForViewer(ctx, frozenUserID, base)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer(self): %v", err)
|
||||
}
|
||||
if reasons := projectionUser(t, selfView, frozenUserID).RestrictionReasons; len(reasons) != 0 {
|
||||
t.Fatalf("self-view restriction = %+v, want none", reasons)
|
||||
}
|
||||
|
||||
batch, err := projector.ForViewers(ctx, []int64{otherViewer, frozenUserID}, base)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewers: %v", err)
|
||||
}
|
||||
if reasons := projectionUser(t, batch[otherViewer], frozenUserID).RestrictionReasons; !reflect.DeepEqual(reasons, domain.AccountFrozenRestrictionReasons()) {
|
||||
t.Fatalf("batch other-view restriction = %+v", reasons)
|
||||
}
|
||||
if reasons := projectionUser(t, batch[frozenUserID], frozenUserID).RestrictionReasons; len(reasons) != 0 {
|
||||
t.Fatalf("batch self-view restriction = %+v, want none", reasons)
|
||||
}
|
||||
|
||||
freezes.items = nil
|
||||
unfrozenView, err := projector.ForViewer(ctx, otherViewer, otherView)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer(after unfreeze): %v", err)
|
||||
}
|
||||
if reasons := projectionUser(t, unfrozenView, frozenUserID).RestrictionReasons; len(reasons) != 0 {
|
||||
t.Fatalf("unfrozen projection retained restriction = %+v", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForViewersEquivalentToForViewer 锁定 fan-out 模板化的核心安全网:ForViewers(viewers, users)
|
||||
// 的每个 viewer 切片必须与逐 viewer 的 ForViewer(viewer, users) 字节等价(隐私/改名/头像投影
|
||||
// 不能因 O(owner) 模板化而漂移泄漏)。**唯一允许的差异是 personal photo overlay**:v1 模板不做
|
||||
|
|
@ -238,6 +301,20 @@ type fakeProfilePhotos struct {
|
|||
fallback map[int64]domain.ProfilePhotoRef
|
||||
}
|
||||
|
||||
type fakeAccountFreezes struct {
|
||||
items map[int64]domain.AccountFreeze
|
||||
}
|
||||
|
||||
func (f *fakeAccountFreezes) AccountFreezes(_ context.Context, ids []int64) (map[int64]domain.AccountFreeze, error) {
|
||||
out := make(map[int64]domain.AccountFreeze)
|
||||
for _, id := range ids {
|
||||
if freeze, ok := f.items[id]; ok {
|
||||
out[id] = freeze
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p fakeProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return p.CurrentProfilePhotosKind(context.Background(), domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
projector *userprojection.Projector
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +56,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
const (
|
||||
minUsernameLen = 5
|
||||
maxUsernameLen = 32
|
||||
|
|
@ -77,6 +82,7 @@ func NewService(users store.UserStore, opts ...Option) *Service {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
|
@ -359,6 +365,56 @@ func (s *Service) SetVerified(ctx context.Context, userID int64, verified bool)
|
|||
return s.projectOne(ctx, userID, updated)
|
||||
}
|
||||
|
||||
// SetScamFake 设置/取消用户的 scam 与 fake 标记(bot 复用同一路径)。scam/fake
|
||||
// 是账号基础事实,所有 user 投影统一消费;写后刷新基础缓存以便投影即时可见。
|
||||
func (s *Service) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) {
|
||||
if userID == 0 {
|
||||
return domain.User{}, ErrNotAuthorized
|
||||
}
|
||||
if scam && fake {
|
||||
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if u.Scam == scam && u.Fake == fake {
|
||||
return u, nil
|
||||
}
|
||||
updated, err := s.users.SetScamFake(ctx, userID, scam, fake)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.refreshCachedUsers(ctx, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// SetSupport 设置/取消用户的 support 标记(官方客服账号)。写后刷新基础缓存。
|
||||
func (s *Service) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) {
|
||||
if userID == 0 {
|
||||
return domain.User{}, ErrNotAuthorized
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if u.Support == support {
|
||||
return u, nil
|
||||
}
|
||||
updated, err := s.users.SetSupport(ctx, userID, support)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.refreshCachedUsers(ctx, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// SweepExpiredPremium 清理到期会员(store 把过期行清 NULL)并失效用户缓存,
|
||||
// 返回清理后的用户,供 RPC 层向本人在线 session 推 updateUser。premium 下发
|
||||
// 正确性由读取路径即时派生保证,这里只做收尾与通知。
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue