From 6426ea154e5b6b33ba1d0e3c66d487da53a9a648 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Sun, 29 Nov 2020 22:55:47 +0100 Subject: [PATCH] #819 add SendJWTHeader auth option, expose it on /config --- backend/app/cmd/server.go | 7 +- backend/app/rest/api/rest.go | 3 + backend/go.mod | 2 +- backend/go.sum | 6 + .../vendor/github.com/go-pkgz/auth/README.md | 101 ++++- .../vendor/github.com/go-pkgz/auth/auth.go | 9 + backend/vendor/github.com/go-pkgz/auth/go.mod | 1 + backend/vendor/github.com/go-pkgz/auth/go.sum | 2 + .../go-pkgz/auth/middleware/auth.go | 31 ++ .../go-pkgz/auth/middleware/user_updater.go | 2 +- .../go-pkgz/auth/provider/custom_server.go | 2 + .../go-pkgz/auth/provider/direct.go | 82 +++- .../go-pkgz/auth/provider/service.go | 2 +- .../go-pkgz/auth/provider/telegram.go | 415 ++++++++++++++++++ .../github.com/go-pkgz/auth/token/jwt.go | 6 + .../github.com/go-pkgz/auth/token/user.go | 11 + backend/vendor/modules.txt | 2 +- 17 files changed, 660 insertions(+), 24 deletions(-) create mode 100644 backend/vendor/github.com/go-pkgz/auth/provider/telegram.go diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index b35bc325..4f470514 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -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 diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 30e99a53..113ec050 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -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{} diff --git a/backend/go.mod b/backend/go.mod index 8ba894dc..bb6b388c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index e292fb1a..b70e62ec 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/vendor/github.com/go-pkgz/auth/README.md b/backend/vendor/github.com/go-pkgz/auth/README.md index 1cae0be0..3369a2a3 100644 --- a/backend/vendor/github.com/go-pkgz/auth/README.md +++ b/backend/vendor/github.com/go-pkgz/auth/README.md @@ -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 sub–routes. @@ -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//login?user=&passwd=&aud=&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//login?user=&passwd=&aud=&session=[1|0] + ``` +* POST request could be encoded as application/x-www-form-urlencoded or application/json: + ``` + POST /auth//login?session=[1|0] + body: application/x-www-form-urlencoded + user=&passwd=&aud= + ``` + ``` + POST /auth//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=&start=` or `https://t.me//?start=` + +Use the following routes to interact with provider: +1. `/auth//login` - Obtain auth token. Returns JSON object with `bot` (bot username) and `token` (token itself) fields. +2. `/auth//login?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//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 diff --git a/backend/vendor/github.com/go-pkgz/auth/auth.go b/backend/vendor/github.com/go-pkgz/auth/auth.go index 1cbddebe..4953f46e 100644 --- a/backend/vendor/github.com/go-pkgz/auth/auth.go +++ b/backend/vendor/github.com/go-pkgz/auth/auth.go @@ -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 diff --git a/backend/vendor/github.com/go-pkgz/auth/go.mod b/backend/vendor/github.com/go-pkgz/auth/go.mod index f15816fa..6ebf079e 100644 --- a/backend/vendor/github.com/go-pkgz/auth/go.mod +++ b/backend/vendor/github.com/go-pkgz/auth/go.mod @@ -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 diff --git a/backend/vendor/github.com/go-pkgz/auth/go.sum b/backend/vendor/github.com/go-pkgz/auth/go.sum index 79e556df..391bb015 100644 --- a/backend/vendor/github.com/go-pkgz/auth/go.sum +++ b/backend/vendor/github.com/go-pkgz/auth/go.sum @@ -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= diff --git a/backend/vendor/github.com/go-pkgz/auth/middleware/auth.go b/backend/vendor/github.com/go-pkgz/auth/middleware/auth.go index 7e3be662..5df66244 100644 --- a/backend/vendor/github.com/go-pkgz/auth/middleware/auth.go +++ b/backend/vendor/github.com/go-pkgz/auth/middleware/auth.go @@ -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 +} diff --git a/backend/vendor/github.com/go-pkgz/auth/middleware/user_updater.go b/backend/vendor/github.com/go-pkgz/auth/middleware/user_updater.go index 33b3a706..34dc5dcd 100644 --- a/backend/vendor/github.com/go-pkgz/auth/middleware/user_updater.go +++ b/backend/vendor/github.com/go-pkgz/auth/middleware/user_updater.go @@ -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) { diff --git a/backend/vendor/github.com/go-pkgz/auth/provider/custom_server.go b/backend/vendor/github.com/go-pkgz/auth/provider/custom_server.go index 32cdfbb2..51059def 100644 --- a/backend/vendor/github.com/go-pkgz/auth/provider/custom_server.go +++ b/backend/vendor/github.com/go-pkgz/auth/provider/custom_server.go @@ -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{ diff --git a/backend/vendor/github.com/go-pkgz/auth/provider/direct.go b/backend/vendor/github.com/go-pkgz/auth/provider/direct.go index c963fd37..46f7f475 100644 --- a/backend/vendor/github.com/go-pkgz/auth/provider/direct.go +++ b/backend/vendor/github.com/go-pkgz/auth/provider/direct.go @@ -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) {} diff --git a/backend/vendor/github.com/go-pkgz/auth/provider/service.go b/backend/vendor/github.com/go-pkgz/auth/provider/service.go index e004fae9..6220caab 100644 --- a/backend/vendor/github.com/go-pkgz/auth/provider/service.go +++ b/backend/vendor/github.com/go-pkgz/auth/provider/service.go @@ -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 } diff --git a/backend/vendor/github.com/go-pkgz/auth/provider/telegram.go b/backend/vendor/github.com/go-pkgz/auth/provider/telegram.go new file mode 100644 index 00000000..3d541eba --- /dev/null +++ b/backend/vendor/github.com/go-pkgz/auth/provider/telegram.go @@ -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) +} diff --git a/backend/vendor/github.com/go-pkgz/auth/token/jwt.go b/backend/vendor/github.com/go-pkgz/auth/token/jwt.go index c830323a..99e5acfd 100644 --- a/backend/vendor/github.com/go-pkgz/auth/token/jwt.go +++ b/backend/vendor/github.com/go-pkgz/auth/token/jwt.go @@ -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()) diff --git a/backend/vendor/github.com/go-pkgz/auth/token/user.go b/backend/vendor/github.com/go-pkgz/auth/token/user.go index c78648ed..70f26a3b 100644 --- a/backend/vendor/github.com/go-pkgz/auth/token/user.go +++ b/backend/vendor/github.com/go-pkgz/auth/token/user.go @@ -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 +} diff --git a/backend/vendor/modules.txt b/backend/vendor/modules.txt index 64dd9318..113e970c 100644 --- a/backend/vendor/modules.txt +++ b/backend/vendor/modules.txt @@ -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