Files
remark42/backend/vendor/github.com/go-pkgz/auth/provider/telegram.go
T

426 lines
10 KiB
Go

package provider
//go:generate moq -out telegram_moq_test.go . TelegramAPI
import (
"context"
"crypto/sha1"
"encoding/json"
"fmt"
"io"
"net/http"
neturl "net/url"
"strings"
"sync"
"time"
"github.com/go-pkgz/repeater"
"github.com/go-pkgz/rest"
"github.com/golang-jwt/jwt"
"github.com/pkg/errors"
"github.com/go-pkgz/auth/logger"
authtoken "github.com/go-pkgz/auth/token"
)
// TelegramHandler implements login via telegram
type TelegramHandler struct {
logger.L
ProviderName string
ErrorMsg, SuccessMsg string
TokenService TokenService
AvatarSaver AvatarSaver
Telegram TelegramAPI
username string // bot username
requests struct {
sync.RWMutex
data map[string]tgAuthRequest
}
}
type tgAuthRequest struct {
confirmed bool // whether login request has been confirmed and user info set
expires time.Time
user *authtoken.User
}
// TelegramAPI is used for interacting with telegram API
type TelegramAPI interface {
GetUpdates(ctx context.Context) (*telegramUpdate, error)
Avatar(ctx context.Context, userID int) (string, error)
Send(ctx context.Context, id int, text string) error
BotInfo(ctx context.Context) (*botInfo, error)
}
// changed in tests
var tgPollInterval = time.Second
// Run starts processing login requests sent in Telegram
// Blocks caller
func (th *TelegramHandler) Run(ctx context.Context) error {
// Initialization
info, err := th.Telegram.BotInfo(ctx)
if err != nil {
return errors.Wrap(err, "failed to fetch bot info")
}
th.requests.Lock()
th.requests.data = make(map[string]tgAuthRequest)
th.requests.Unlock()
th.username = info.Username
ticker := time.NewTicker(tgPollInterval)
for {
select {
case <-ctx.Done():
ticker.Stop()
return ctx.Err()
case <-ticker.C:
err := th.processUpdates(ctx)
if err != nil {
th.Logf("Error while processing updates: %v", err)
continue
}
// Purge expired requests
now := time.Now()
th.requests.Lock()
for key, req := range th.requests.data {
if now.After(req.expires) {
delete(th.requests.data, key)
}
}
th.requests.Unlock()
}
}
}
type telegramUpdate struct {
Result []struct {
UpdateID int `json:"update_id"`
Message struct {
Chat struct {
ID int `json:"id"`
Name string `json:"first_name"`
Type string `json:"type"`
} `json:"chat"`
Text string `json:"text"`
} `json:"message"`
} `json:"result"`
}
// processUpdates processes a batch of updates from telegram servers
// Returns offset for subsequent calls
func (th *TelegramHandler) processUpdates(ctx context.Context) error {
updates, err := th.Telegram.GetUpdates(ctx)
if err != nil {
return err
}
for _, update := range updates.Result {
if update.Message.Chat.Type != "private" {
continue
}
if !strings.HasPrefix(update.Message.Text, "/start ") {
err := th.Telegram.Send(ctx, update.Message.Chat.ID, th.ErrorMsg)
if err != nil {
th.Logf("failed to notify telegram peer: %v", err)
}
continue
}
token := strings.TrimPrefix(update.Message.Text, "/start ")
th.requests.RLock()
authRequest, ok := th.requests.data[token]
if !ok { // No such token
th.requests.RUnlock()
err := th.Telegram.Send(ctx, update.Message.Chat.ID, th.ErrorMsg)
if err != nil {
th.Logf("failed to notify telegram peer: %v", err)
}
continue
}
th.requests.RUnlock()
avatarURL, err := th.Telegram.Avatar(ctx, update.Message.Chat.ID)
if err != nil {
th.Logf("failed to get user avatar: %v", err)
continue
}
id := th.ProviderName + "_" + authtoken.HashID(sha1.New(), fmt.Sprint(update.Message.Chat.ID))
authRequest.confirmed = true
authRequest.user = &authtoken.User{
ID: id,
Name: update.Message.Chat.Name,
Picture: avatarURL,
}
th.requests.Lock()
th.requests.data[token] = authRequest
th.requests.Unlock()
err = th.Telegram.Send(ctx, update.Message.Chat.ID, th.SuccessMsg)
if err != nil {
th.Logf("failed to notify telegram peer: %v", err)
}
}
return nil
}
// Name of the provider
func (th *TelegramHandler) Name() string { return th.ProviderName }
// Default token lifetime. Changed in tests
var tgAuthRequestLifetime = time.Minute * 10
// LoginHandler generates and verifies login requests
func (th *TelegramHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
queryToken := r.URL.Query().Get("token")
if queryToken == "" {
// GET /login (No token supplied)
// Generate and send token
token, err := randToken()
if err != nil {
rest.SendErrorJSON(w, r, th.L, http.StatusInternalServerError, err, "failed to generate code")
return
}
th.requests.Lock()
if th.requests.data == nil {
th.requests.Unlock()
rest.SendErrorJSON(w, r, th.L, http.StatusInternalServerError, errors.New("run goroutine is not running"), "failed to process login request")
return
}
th.requests.data[token] = tgAuthRequest{
expires: time.Now().Add(tgAuthRequestLifetime),
}
th.requests.Unlock()
rest.RenderJSON(w, struct {
Token string `json:"token"`
Bot string `json:"bot"`
}{token, th.username})
return
}
// GET /login?token=blah
th.requests.RLock()
authRequest, ok := th.requests.data[queryToken]
th.requests.RUnlock()
if !ok || time.Now().After(authRequest.expires) {
th.requests.Lock()
delete(th.requests.data, queryToken)
th.requests.Unlock()
rest.SendErrorJSON(w, r, nil, http.StatusNotFound, nil, "request expired")
return
}
if !authRequest.confirmed {
rest.SendErrorJSON(w, r, nil, http.StatusNotFound, nil, "request not yet confirmed")
return
}
u, err := setAvatar(th.AvatarSaver, *authRequest.user, &http.Client{Timeout: 5 * time.Second})
if err != nil {
rest.SendErrorJSON(w, r, th.L, http.StatusInternalServerError, err, "failed to save avatar to proxy")
return
}
claims := authtoken.Claims{
User: &u,
StandardClaims: jwt.StandardClaims{
Audience: r.URL.Query().Get("site"),
Id: queryToken,
Issuer: th.ProviderName,
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
},
SessionOnly: false, // TODO
}
if _, err := th.TokenService.Set(w, claims); err != nil {
rest.SendErrorJSON(w, r, th.L, http.StatusInternalServerError, err, "failed to set token")
return
}
rest.RenderJSON(w, claims.User)
// Delete request
th.requests.Lock()
defer th.requests.Unlock()
delete(th.requests.data, queryToken)
}
// AuthHandler does nothing since we don't have any callbacks
func (th *TelegramHandler) AuthHandler(_ http.ResponseWriter, _ *http.Request) {}
// LogoutHandler - GET /logout
func (th *TelegramHandler) LogoutHandler(w http.ResponseWriter, _ *http.Request) {
th.TokenService.Reset(w)
}
// tgAPI implements TelegramAPI
type tgAPI struct {
logger.L
token string
client *http.Client
// Identifier of the first update to be requested.
// Should be equal to LastSeenUpdateID + 1
// See https://core.telegram.org/bots/api#getupdates
updateOffset int
}
// NewTelegramAPI returns initialized TelegramAPI implementation
func NewTelegramAPI(token string, client *http.Client) TelegramAPI {
return &tgAPI{
client: client,
token: token,
}
}
// GetUpdates fetches incoming updates
func (tg *tgAPI) GetUpdates(ctx context.Context) (*telegramUpdate, error) {
url := `getUpdates?allowed_updates=["message"]`
if tg.updateOffset != 0 {
url += fmt.Sprintf("&offset=%d", tg.updateOffset)
}
var result telegramUpdate
err := tg.request(ctx, url, &result)
if err != nil {
return nil, errors.Wrap(err, "failed to fetch updates")
}
for _, u := range result.Result {
if u.UpdateID >= tg.updateOffset {
tg.updateOffset = u.UpdateID + 1
}
}
return &result, err
}
// Send sends a message to telegram peer
func (tg *tgAPI) Send(ctx context.Context, id int, msg string) error {
url := fmt.Sprintf("sendMessage?chat_id=%d&text=%s", id, neturl.PathEscape(msg))
return tg.request(ctx, url, &struct{}{})
}
// Avatar returns URL to user avatar
func (tg *tgAPI) Avatar(ctx context.Context, id int) (string, error) {
// Get profile pictures
url := fmt.Sprintf(`getUserProfilePhotos?user_id=%d`, id)
var profilePhotos = struct {
Result struct {
Photos [][]struct {
ID string `json:"file_id"`
} `json:"photos"`
} `json:"result"`
}{}
if err := tg.request(ctx, url, &profilePhotos); err != nil {
return "", err
}
// User does not have profile picture set or it is hidden in privacy settings
if len(profilePhotos.Result.Photos) == 0 || len(profilePhotos.Result.Photos[0]) == 0 {
return "", nil
}
// Get max possible picture size
last := len(profilePhotos.Result.Photos[0]) - 1
fileID := profilePhotos.Result.Photos[0][last].ID
url = fmt.Sprintf(`getFile?file_id=%s`, fileID)
var fileMetadata = struct {
Result struct {
Path string `json:"file_path"`
} `json:"result"`
}{}
if err := tg.request(ctx, url, &fileMetadata); err != nil {
return "", err
}
avatarURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", tg.token, fileMetadata.Result.Path)
return avatarURL, nil
}
type botInfo struct {
ID int `json:"id"`
Name string `json:"first_name"`
Username string `json:"username"`
}
// BotInfo returns info about configured bot
func (tg *tgAPI) BotInfo(ctx context.Context) (*botInfo, error) {
var resp = struct {
Result *botInfo `json:"result"`
}{}
err := tg.request(ctx, "getMe", &resp)
if err != nil {
return nil, errors.Wrap(err, "failed to fetch bot info")
}
return resp.Result, nil
}
func (tg *tgAPI) request(ctx context.Context, method string, data interface{}) error {
repeat := repeater.NewDefault(3, time.Millisecond*50)
return repeat.Do(ctx, func() error {
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", tg.token, method)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return errors.Wrap(err, "failed to create request")
}
resp, err := tg.client.Do(req)
if err != nil {
return errors.Wrap(err, "failed to send request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return tg.parseError(resp.Body)
}
if err = json.NewDecoder(resp.Body).Decode(data); err != nil {
return errors.Wrap(err, "failed to decode json response")
}
return nil
})
}
func (tg *tgAPI) parseError(r io.Reader) error {
var tgErr = struct {
Description string `json:"description"`
}{}
if err := json.NewDecoder(r).Decode(&tgErr); err != nil {
return errors.Wrap(err, "can't decode error")
}
return errors.Errorf("telegram returned error: %v", tgErr.Description)
}