#819 add SendJWTHeader auth option, expose it on /config

This commit is contained in:
Dmitry Verkhoturov
2020-11-29 18:18:22 -06:00
committed by Umputun
parent 5b4e492c1b
commit 6426ea154e
17 changed files with 660 additions and 24 deletions
+5 -2
View File
@@ -79,8 +79,9 @@ type ServerCommand struct {
Auth struct {
TTL struct {
JWT time.Duration `long:"jwt" env:"JWT" default:"5m" description:"jwt TTL"`
Cookie time.Duration `long:"cookie" env:"COOKIE" default:"200h" description:"auth cookie TTL"`
JWT time.Duration `long:"jwt" env:"JWT" default:"5m" description:"jwt TTL"`
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"send JWT as a header instead of cookie"`
Cookie time.Duration `long:"cookie" env:"COOKIE" default:"200h" description:"auth cookie TTL"`
} `group:"ttl" namespace:"ttl" env-namespace:"TTL"`
Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"`
Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"`
@@ -455,6 +456,7 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) {
AnonVote: s.AnonymousVote && s.RestrictVoteIP,
SimpleView: s.SimpleView,
ProxyCORS: s.ProxyCORS,
SendJWTHeader: s.Auth.TTL.SendJWTHeader,
}
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore
@@ -908,6 +910,7 @@ func (s *ServerCommand) makeAuthenticator(ds *service.DataStore, avas avatar.Sto
URL: strings.TrimSuffix(s.RemarkURL, "/"),
Issuer: "remark42",
TokenDuration: s.Auth.TTL.JWT,
SendJWTHeader: s.Auth.TTL.SendJWTHeader,
CookieDuration: s.Auth.TTL.Cookie,
SecureCookies: strings.HasPrefix(s.RemarkURL, "https://"),
SecretReader: token.SecretFunc(func(aud string) (string, error) { // get secret per site
+3
View File
@@ -62,6 +62,7 @@ type Rest struct {
EmojiEnabled bool
SimpleView bool
ProxyCORS bool
SendJWTHeader bool
SSLConfig SSLConfig
httpsServer *http.Server
@@ -422,6 +423,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
EmailNotifications bool `json:"email_notifications"`
EmojiEnabled bool `json:"emoji_enabled"`
SimpleView bool `json:"simple_view"`
SendJWTHeader bool `json:"send_jwt_header"`
}{
Version: s.Version,
EditDuration: int(s.DataService.EditDuration.Seconds()),
@@ -437,6 +439,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
EmojiEnabled: s.EmojiEnabled,
AnonVote: s.AnonVote,
SimpleView: s.SimpleView,
SendJWTHeader: s.SendJWTHeader,
}
cnf.Auth = []string{}
+1 -1
View File
@@ -12,7 +12,7 @@ require (
github.com/go-chi/chi v4.1.1+incompatible
github.com/go-chi/cors v1.1.1
github.com/go-chi/render v1.0.1
github.com/go-pkgz/auth v0.11.0
github.com/go-pkgz/auth v1.13.0
github.com/go-pkgz/jrpc v0.2.0
github.com/go-pkgz/lcw v0.7.1
github.com/go-pkgz/lgr v0.7.0
+6
View File
@@ -58,6 +58,12 @@ github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
github.com/go-pkgz/auth v0.11.0 h1:xi9Y0KGZUftVLx/8tGdvIfUk/+/4QLBVL6k9A8C4ycM=
github.com/go-pkgz/auth v0.11.0/go.mod h1:NzVqlTW0E9JXVdAaWRq81XZjICgHnNaNdUfE3CbS2T4=
github.com/go-pkgz/auth v0.12.1 h1:tLdxRnK444PUZK4AGgTc8GJ4MxX4cEIIv7BMlpNLySk=
github.com/go-pkgz/auth v0.12.1/go.mod h1:NzVqlTW0E9JXVdAaWRq81XZjICgHnNaNdUfE3CbS2T4=
github.com/go-pkgz/auth v0.12.2-0.20201016021254-bc202515c232 h1:CtzGRs1LY06CWBEj7TSiOcH7M6vKHB7DBgCSy9/3QdU=
github.com/go-pkgz/auth v0.12.2-0.20201016021254-bc202515c232/go.mod h1:+8DMssa9T0C75rvwfsgNXatOfweaMF8UK9n1dgPLLiU=
github.com/go-pkgz/auth v1.13.0 h1:CzCYmf9zgfTPEVRL3mEebd5g281QF3+0+wtLpZMxBvU=
github.com/go-pkgz/auth v1.13.0/go.mod h1:+8DMssa9T0C75rvwfsgNXatOfweaMF8UK9n1dgPLLiU=
github.com/go-pkgz/expirable-cache v0.0.3 h1:rTh6qNPp78z0bQE6HDhXBHUwqnV9i09Vm6dksJLXQDc=
github.com/go-pkgz/expirable-cache v0.0.3/go.mod h1:+IauqN00R2FqNRLCLA+X5YljQJrwB179PfiAoMPlTlQ=
github.com/go-pkgz/jrpc v0.2.0 h1:CLy/eZyekjraVrxZV18N2R1mYLMJ/nWrgdfyIOGPY/E=
+92 -9
View File
@@ -1,7 +1,7 @@
# auth - authentication via oauth2, direct and email
[![Build Status](https://github.com/go-pkgz/auth/workflows/build/badge.svg)](https://github.com/go-pkgz/auth/actions) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/auth/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/auth?branch=master) [![godoc](https://godoc.org/github.com/go-pkgz/auth?status.svg)](https://pkg.go.dev/github.com/go-pkgz/auth?tab=doc)
This library provides "social login" with Github, Google, Facebook, Microsoft, Twitter, Yandex and Battle.net as well as custom auth providers and email verification.
This library provides "social login" with Github, Google, Facebook, Microsoft, Twitter, Yandex, Battle.net and Telegram as well as custom auth providers and email verification.
- Multiple oauth2 providers can be used at the same time
- Special `dev` provider allows local testing and development
@@ -20,6 +20,7 @@ This library provides "social login" with Github, Google, Facebook, Microsoft, T
- Pre-auth and post-auth hooks to handle custom use cases.
- Middleware for easy integration into http routers
- Wrappers to extract user info from the request
- Role based access control
## Install
@@ -77,6 +78,7 @@ func main() {
- `middleware.Auth` - requires authenticated user
- `middleware.Admin` - requires authenticated admin user
- `middleware.Trace` - doesn't require authenticated user, but adds user info to request
- `middleware.RBAC` - requires authenticated user with passed role(s)
Also, there is a special middleware `middleware.UpdateUser` for population and modifying UserInfo in every request. See "Customization" for more details.
@@ -86,7 +88,7 @@ Generally, adding support of `auth` includes a few relatively simple steps:
1. Setup `auth.Opts` structure with all parameters. Each of them [documented](https://github.com/go-pkgz/auth/blob/master/auth.go#L29) and most of parameters are optional and have sane defaults.
2. [Create](https://github.com/go-pkgz/auth/blob/master/auth.go#L56) the new `auth.Service` with provided options.
3. [Add all](https://github.com/go-pkgz/auth/blob/master/auth.go#L149) desirable authentication providers. Currently supported Github, Google, Facebook and Yandex
3. [Add all](https://github.com/go-pkgz/auth/blob/master/auth.go#L149) desirable authentication providers.
4. Retrieve [middleware](https://github.com/go-pkgz/auth/blob/master/auth.go#L144) and [http handlers](https://github.com/go-pkgz/auth/blob/master/auth.go#L105) from `auth.Service`
5. Wire auth and avatar handlers into http router as subroutes.
@@ -148,7 +150,27 @@ In addition to oauth2 providers `auth.Service` allows to use direct user-defined
Such provider acts like any other, i.e. will be registered as `/auth/local/login`.
The API for this provider - `GET /auth/<name>/login?user=<user>&passwd=<password>&aud=<site_id>&session=[1|0]`
The API for this provider supports both GET and POST requests:
* GET request with user credentials provided as query params:
```
GET /auth/<name>/login?user=<user>&passwd=<password>&aud=<site_id>&session=[1|0]
```
* POST request could be encoded as application/x-www-form-urlencoded or application/json:
```
POST /auth/<name>/login?session=[1|0]
body: application/x-www-form-urlencoded
user=<user>&passwd=<password>&aud=<site_id>
```
```
POST /auth/<name>/login?session=[1|0]
body: application/json
{
"user": "name",
"passwd": "xyz",
"aud": "bar",
}
```
_note: password parameter doesn't have to be naked/real password and can be any kind of password hash prepared by caller._
@@ -187,6 +209,57 @@ The API for this provider:
The provider acts like any other, i.e. will be registered as `/auth/email/login`.
### Telegram
Telegram provider allows your users to log in with Telegram account. First, you will need to create your bot.
Contact [@BotFather](https://t.me/botfather) and follow his instructions to create your own bot (call it, for example, "My site auth bot")
Next initialize TelegramHandler with following parameters:
* `ProviderName` - Any unique name to distinguish between providers
* `SuccessMsg` - Message sent to user on successfull authentication
* `ErrorMsg` - Message sent on errors (e.g. login request expired)
* `Telegram` - Telegram API implementation. Use provider.NewTelegramAPI with following arguments
1. The secret token bot father gave you
2. An http.Client for accessing Telegram API's
```go
token := os.Getenv("TELEGRAM_TOKEN")
telegram := provider.TelegramHandler{
ProviderName: "telegram",
ErrorMsg: "❌ Invalid auth request. Please try clicking link again.",
SuccessMsg: "✅ You have successfully authenticated!",
Telegram: provider.NewTelegramAPI(token, http.DefaultClient),
L: log.Default(),
TokenService: service.TokenService(),
AvatarSaver: service.AvatarProxy(),
}
```
After that run provider and register it's handlers:
```go
// Run Telegram provider in the background
go func() {
err := telegram.Run(context.Background())
if err != nil {
log.Fatalf("[PANIC] failed to start telegram: %v", err)
}
}()
// Register Telegram provider
service.AddCustomHandler(&telegram)
```
Now all your users have to do is click one of the following links and press **start**
`tg://resolve?domain=<botname>&start=<token>` or `https://t.me/<botname>/?start=<token>`
Use the following routes to interact with provider:
1. `/auth/<providerName>/login` - Obtain auth token. Returns JSON object with `bot` (bot username) and `token` (token itself) fields.
2. `/auth/<providerName>/login?token=<token>` - Check if auth request has been confirmed (i.e. user pressed start). Sets session cookie and returns user info on success, errors with 404 otherwise.
3. `/auth/<providerName>/logout` - Invalidate user session.
### Custom oauth2
This provider brings two extra functions:
@@ -234,7 +307,7 @@ In order to add a new oauth2 provider following input is required:
WithLoginPage: true,
}
prov := provider.NewCustomServer(srv, sopts)
// Start server
go prov.Run(context.Background())
```
@@ -246,12 +319,22 @@ In order to add a new oauth2 provider following input is required:
service.AddCustomProvider("custom123", auth.Client{Cid: "cid", Csecret: "csecret"}, prov.HandlerOpt)
```
### Self-implemented auth handler
Additionally it is possible to implement own auth handler. It may be useful if auth provider does not conform to oauth standard. Self-implemented handler has to implement `provider.Provider` interface.
```go
// customHandler implements provider.Provider interface
c := customHandler{}
// add customHandler to stack of auth handlers
service.AddCustomHandler(c)
```
### Customization
There are several ways to adjust functionality of the library:
1. `SecretReader` - interface with a single method `Get(aud string) string` to return the secret used for JWT signing and verification
1. `ClaimsUpdater` - interface with `Update(claims Claims) Claims` method. This is the primary way to alter a token at login time and add any attributes, set ip, email, admin status and so on.
1. `ClaimsUpdater` - interface with `Update(claims Claims) Claims` method. This is the primary way to alter a token at login time and add any attributes, set ip, email, admin status, roles and so on.
1. `Validator` - interface with `Validate(token string, claims Claims) bool` method. This is post-token hook and will be called on **each request** wrapped with `Auth` middleware. This will be the place for special logic to reject some tokens or users.
1. `UserUpdater` - interface with `Update(claims token.User) token.User` method. This method will be called on **each request** wrapped with `UpdateUser` middleware. This will be the place for special logic modify User Info in request context. [Example of usage.]((https://github.com/go-pkgz/auth/blob/master/_example/main.go#L148))
@@ -337,11 +420,11 @@ _instructions for google oauth2 setup borrowed from [oauth2_proxy](https://githu
#### Microsoft Auth Provider
1 .Register a new application [using the Azure portal](https://docs.microsoft.com/en-us/graph/auth-register-app-v2).
2. Under **"Authentication/Platform configurations/Web"** enter the correct url constructed as domain + `/auth/microsoft/callback`. i.e. `https://example.mysite.com/auth/microsoft/callback`
3. In "Overview" take note of the **Application (client) ID**
1. Register a new application [using the Azure portal](https://docs.microsoft.com/en-us/graph/auth-register-app-v2).
2. Under **"Authentication/Platform configurations/Web"** enter the correct url constructed as domain + `/auth/microsoft/callback`. i.e. `https://example.mysite.com/auth/microsoft/callback`
3. In "Overview" take note of the **Application (client) ID**
4. Choose the new project from the top right project dropdown (only if another project is selected)
5. Select "Certificates & secrets" and click on "+ New Client Secret".
5. Select "Certificates & secrets" and click on "+ New Client Secret".
#### GitHub Auth Provider
+9
View File
@@ -53,6 +53,8 @@ type Opts struct {
XSRFHeaderKey string // default "X-XSRF-TOKEN"
JWTQuery string // default "token"
SendJWTHeader bool // if enabled send JWT as a header instead of cookie
Issuer string // optional value for iss claim, usually the application name, default "go-pkgz/auth"
URL string // root url for the rest service, i.e. http://blah.example.com, required
@@ -105,6 +107,7 @@ func NewService(opts Opts) (res *Service) {
JWTHeaderKey: opts.JWTHeaderKey,
XSRFCookieName: opts.XSRFCookieName,
XSRFHeaderKey: opts.XSRFHeaderKey,
SendJWTHeader: opts.SendJWTHeader,
JWTQuery: opts.JWTQuery,
Issuer: res.issuer,
AudienceReader: opts.AudienceReader,
@@ -284,6 +287,12 @@ func (s *Service) AddVerifProvider(name, msgTmpl string, sender provider.Sender)
s.authMiddleware.Providers = s.providers
}
// AddCustomHandler adds user-defined self-implemented handler of auth provider
func (s *Service) AddCustomHandler(handler provider.Provider) {
s.providers = append(s.providers, provider.NewService(handler))
s.authMiddleware.Providers = s.providers
}
// DevAuth makes dev oauth2 server, for testing and development only!
func (s *Service) DevAuth() (*provider.DevAuthServer, error) {
p, err := s.Provider("dev") // peak dev provider
+1
View File
@@ -5,6 +5,7 @@ go 1.14
require (
github.com/dghubble/oauth1 v0.6.0
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/go-pkgz/repeater v1.1.3
github.com/go-pkgz/rest v1.5.0
github.com/microcosm-cc/bluemonday v1.0.2
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022
+2
View File
@@ -18,6 +18,8 @@ github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/go-pkgz/repeater v1.1.3 h1:q6+JQF14ESSy28Dd7F+wRelY4F+41HJ0LEy/szNnMiE=
github.com/go-pkgz/repeater v1.1.3/go.mod h1:hVTavuO5x3Gxnu8zW7d6sQBfAneKV8X2FjU48kGfpKw=
github.com/go-pkgz/rest v1.5.0 h1:C8SxXcXza4GiUUAn/95iCkvoIrGbS30qpwK19iqlrWQ=
github.com/go-pkgz/rest v1.5.0/go.mod h1:nQaM3RhSTUAmbBZWY4hfe4buyeC9VckvhoCktiQXJxI=
github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0=
+31
View File
@@ -7,6 +7,7 @@ package middleware
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/pkg/errors"
@@ -187,3 +188,33 @@ func (a *Authenticator) basicAdminUser(r *http.Request) bool {
return true
}
// RBAC middleware allows role based control for routes
// this handler internally wrapped with auth(true) to avoid situation if RBAC defined without prior Auth
func (a *Authenticator) RBAC(roles ...string) func(http.Handler) http.Handler {
f := func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
user, err := token.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var matched bool
for _, role := range roles {
if strings.EqualFold(role, user.Role) {
matched = true
break
}
}
if !matched {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
h.ServeHTTP(w, r)
}
return a.auth(true)(http.HandlerFunc(fn)) // enforce auth
}
return f
}
+1 -1
View File
@@ -21,7 +21,7 @@ func (f UserUpdFunc) Update(user token.User) token.User {
}
// UpdateUser update user info with UserUpdater if it exists in request's context. Otherwise do nothing.
// should be places after either Auth, Trace or AdminOnly middleware.
// should be placed after either Auth, Trace. AdminOnly or RBAC middleware.
func (a *Authenticator) UpdateUser(upd UserUpdater) func(http.Handler) http.Handler {
f := func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
+2
View File
@@ -78,11 +78,13 @@ func (c *CustomServer) Run(ctx context.Context) {
u, err := url.Parse(c.URL)
if err != nil {
c.Logf("[ERROR] failed to parse service base URL=%s", c.URL)
return
}
_, port, err := net.SplitHostPort(u.Host)
if err != nil {
c.Logf("[ERROR] failed to get port from URL=%s", c.URL)
return
}
c.httpServer = &http.Server{
+73 -9
View File
@@ -2,17 +2,24 @@ package provider
import (
"crypto/sha1"
"errors"
"encoding/json"
"mime"
"net/http"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/go-pkgz/rest"
"github.com/pkg/errors"
"github.com/go-pkgz/auth/logger"
"github.com/go-pkgz/auth/token"
)
const (
// MaxHTTPBodySize defines max http body size
MaxHTTPBodySize = 1024 * 1024
)
// DirectHandler implements non-oauth2 provider authorizing user in traditional way with storage
// with users and hashes
type DirectHandler struct {
@@ -37,21 +44,45 @@ func (f CredCheckerFunc) Check(user, password string) (ok bool, err error) {
return f(user, password)
}
// credentials holds user credentials
type credentials struct {
User string `json:"user"`
Password string `json:"passwd"`
Audience string `json:"aud"`
}
// Name of the handler
func (p DirectHandler) Name() string { return p.ProviderName }
// LoginHandler checks "user" and "passwd" against data store and makes jwt if all passed
// GET /something?user=name&password=xyz&sess=[0|1]
// LoginHandler checks "user" and "passwd" against data store and makes jwt if all passed.
//
// GET /something?user=name&passwd=xyz&aud=bar&sess=[0|1]
//
// POST /something?sess[0|1]
// Accepts application/x-www-form-urlencoded or application/json encoded requests.
//
// application/x-www-form-urlencoded body example:
// user=name&passwd=xyz&aud=bar
//
// application/json body example:
// {
// "user": "name",
// "passwd": "xyz",
// "aud": "bar",
// }
func (p DirectHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
user, password := r.URL.Query().Get("user"), r.URL.Query().Get("passwd")
aud := r.URL.Query().Get("aud")
creds, err := p.getCredentials(w, r)
if err != nil {
rest.SendErrorJSON(w, r, p.L, http.StatusBadRequest, err, "failed to parse credentials")
return
}
sessOnly := r.URL.Query().Get("sess") == "1"
if p.CredChecker == nil {
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError,
errors.New("no credential checker"), "no credential checker")
return
}
ok, err := p.CredChecker.Check(user, password)
ok, err := p.CredChecker.Check(creds.User, creds.Password)
if err != nil {
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to check user credentials")
return
@@ -61,8 +92,8 @@ func (p DirectHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
u := token.User{
Name: user,
ID: p.ProviderName + "_" + token.HashID(sha1.New(), user),
Name: creds.User,
ID: p.ProviderName + "_" + token.HashID(sha1.New(), creds.User),
}
u, err = setAvatar(p.AvatarSaver, u, &http.Client{Timeout: 5 * time.Second})
if err != nil {
@@ -81,7 +112,7 @@ func (p DirectHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
StandardClaims: jwt.StandardClaims{
Id: cid,
Issuer: p.Issuer,
Audience: aud,
Audience: creds.Audience,
},
SessionOnly: sessOnly,
}
@@ -93,6 +124,39 @@ func (p DirectHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
rest.RenderJSON(w, r, claims.User)
}
// getCredentials extracts user and password from request
func (p DirectHandler) getCredentials(w http.ResponseWriter, r *http.Request) (credentials, error) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, MaxHTTPBodySize)
}
contentType := r.Header.Get("Content-Type")
if contentType != "" {
mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return credentials{}, err
}
contentType = mt
}
if contentType == "application/json" {
var creds credentials
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
return credentials{}, errors.Wrap(err, "failed to parse request body")
}
return creds, nil
}
if err := r.ParseForm(); err != nil {
return credentials{}, errors.Wrap(err, "failed to parse request")
}
return credentials{
User: r.Form.Get("user"),
Password: r.Form.Get("passwd"),
Audience: r.Form.Get("aud"),
}, nil
}
// AuthHandler doesn't do anything for direct login as it has no callbacks
func (p DirectHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {}
+1 -1
View File
@@ -52,7 +52,7 @@ type Provider interface {
// Handler returns auth routes for given provider
func (p Service) Handler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
+415
View File
@@ -0,0 +1,415 @@
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/dgrijalva/jwt-go"
"github.com/go-pkgz/auth/logger"
authtoken "github.com/go-pkgz/auth/token"
"github.com/go-pkgz/repeater"
"github.com/go-pkgz/rest"
"github.com/pkg/errors"
)
// 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")
}
th.requests.Lock()
th.requests.data[token] = tgAuthRequest{
expires: time.Now().Add(tgAuthRequestLifetime),
}
th.requests.Unlock()
rest.RenderJSON(w, r, 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{
Id: queryToken,
Issuer: th.ProviderName,
},
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, r, claims.User)
// Delete request
th.requests.Lock()
defer th.requests.Unlock()
delete(th.requests.data, queryToken)
}
// AuthHandler does nothing since we're don't have any callbacks
func (th *TelegramHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {}
// LogoutHandler - GET /logout
func (th *TelegramHandler) LogoutHandler(w http.ResponseWriter, r *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)
}
+6
View File
@@ -66,6 +66,7 @@ type Opts struct {
AudienceReader Audience // allowed aud values
Issuer string // optional value for iss claim, usually application name
AudSecrets bool // uses different secret for differed auds. important: adds pre-parsing of unverified token
SendJWTHeader bool // if enabled send JWT as a header instead of cookie
}
// NewService makes JWT service
@@ -226,6 +227,11 @@ func (j *Service) Set(w http.ResponseWriter, claims Claims) (Claims, error) {
return Claims{}, errors.Wrap(err, "failed to make token token")
}
if j.SendJWTHeader {
w.Header().Set(j.JWTHeaderKey, tokenString)
return claims, nil
}
cookieExpiration := 0 // session cookie
if !claims.SessionOnly && claims.Handshake == nil {
cookieExpiration = int(j.CookieDuration.Seconds())
+11
View File
@@ -30,6 +30,7 @@ type User struct {
IP string `json:"ip,omitempty"`
Email string `json:"email,omitempty"`
Attributes map[string]interface{} `json:"attrs,omitempty"`
Role string `json:"role,omitempty"`
}
// SetBoolAttr sets boolean attribute
@@ -145,3 +146,13 @@ func SetUserInfo(r *http.Request, user User) *http.Request {
ctx = context.WithValue(ctx, contextKey("user"), user)
return r.WithContext(ctx)
}
// SetRole sets user role for RBAC
func (u *User) SetRole(role string) {
u.Role = role
}
// GetRole gets user role
func (u *User) GetRole() string {
return u.Role
}
+1 -1
View File
@@ -72,7 +72,7 @@ github.com/go-chi/cors
# github.com/go-chi/render v1.0.1
## explicit
github.com/go-chi/render
# github.com/go-pkgz/auth v0.11.0
# github.com/go-pkgz/auth v1.13.0
## explicit
github.com/go-pkgz/auth
github.com/go-pkgz/auth/avatar