diff options
Diffstat (limited to 'internal/middleware')
-rw-r--r-- | internal/middleware/cachecontrol.go | 35 | ||||
-rw-r--r-- | internal/middleware/cors.go | 86 | ||||
-rw-r--r-- | internal/middleware/extraheaders.go | 37 | ||||
-rw-r--r-- | internal/middleware/gzip.go | 30 | ||||
-rw-r--r-- | internal/middleware/logger.go | 99 | ||||
-rw-r--r-- | internal/middleware/ratelimit.go | 77 | ||||
-rw-r--r-- | internal/middleware/session.go | 95 | ||||
-rw-r--r-- | internal/middleware/session_test.go | 95 | ||||
-rw-r--r-- | internal/middleware/signaturecheck.go | 93 | ||||
-rw-r--r-- | internal/middleware/tokencheck.go | 140 | ||||
-rw-r--r-- | internal/middleware/useragent.go | 39 |
11 files changed, 826 insertions, 0 deletions
diff --git a/internal/middleware/cachecontrol.go b/internal/middleware/cachecontrol.go new file mode 100644 index 000000000..6e88daf1a --- /dev/null +++ b/internal/middleware/cachecontrol.go @@ -0,0 +1,35 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package middleware + +import ( + "strings" + + "github.com/gin-gonic/gin" +) + +// CacheControl returns a new gin middleware which allows callers to control cache settings on response headers. +// +// For directives, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control +func CacheControl(directives ...string) gin.HandlerFunc { + ccHeader := strings.Join(directives, ", ") + return func(c *gin.Context) { + c.Header("Cache-Control", ccHeader) + } +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 000000000..79b72185f --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,86 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package middleware + +import ( + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +// CORS returns a new gin middleware which allows CORS requests to be processed. +// This is necessary in order for web/browser-based clients like Pinafore to work. +func CORS() gin.HandlerFunc { + cfg := cors.Config{ + // todo: use config to customize this + AllowAllOrigins: true, + + // adds the following: + // "chrome-extension://" + // "safari-extension://" + // "moz-extension://" + // "ms-browser-extension://" + AllowBrowserExtensions: true, + AllowMethods: []string{ + "POST", + "PUT", + "DELETE", + "GET", + "PATCH", + "OPTIONS", + }, + AllowHeaders: []string{ + // basic cors stuff + "Origin", + "Content-Length", + "Content-Type", + + // needed to pass oauth bearer tokens + "Authorization", + + // needed for websocket upgrade requests + "Upgrade", + "Sec-WebSocket-Extensions", + "Sec-WebSocket-Key", + "Sec-WebSocket-Protocol", + "Sec-WebSocket-Version", + "Connection", + }, + AllowWebSockets: true, + ExposeHeaders: []string{ + // needed for accessing next/prev links when making GET timeline requests + "Link", + + // needed so clients can handle rate limits + "X-RateLimit-Reset", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-Request-Id", + + // websocket stuff + "Connection", + "Sec-WebSocket-Accept", + "Upgrade", + }, + MaxAge: 2 * time.Minute, + } + + return cors.New(cfg) +} diff --git a/internal/middleware/extraheaders.go b/internal/middleware/extraheaders.go new file mode 100644 index 000000000..c7d4dd3e2 --- /dev/null +++ b/internal/middleware/extraheaders.go @@ -0,0 +1,37 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package middleware + +import "github.com/gin-gonic/gin" + +// ExtraHeaders returns a new gin middleware which adds various extra headers to the response. +func ExtraHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + // Inform all callers which server implementation this is. + c.Header("Server", "gotosocial") + // Prevent google chrome cohort tracking. Originally this was referred + // to as FlocBlock. Floc was replaced by Topics in 2022 and the spec says + // that interest-cohort will also block Topics (as of 2022-Nov). + // + // See: https://smartframe.io/blog/google-topics-api-everything-you-need-to-know + // + // See: https://github.com/patcg-individual-drafts/topics + c.Header("Permissions-Policy", "browsing-topics=()") + } +} diff --git a/internal/middleware/gzip.go b/internal/middleware/gzip.go new file mode 100644 index 000000000..ddea62b63 --- /dev/null +++ b/internal/middleware/gzip.go @@ -0,0 +1,30 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package middleware + +import ( + ginGzip "github.com/gin-contrib/gzip" + "github.com/gin-gonic/gin" +) + +// Gzip returns a gzip gin middleware using default compression. +func Gzip() gin.HandlerFunc { + // todo: make this configurable + return ginGzip.Gzip(ginGzip.DefaultCompression) +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 000000000..7474b0fe0 --- /dev/null +++ b/internal/middleware/logger.go @@ -0,0 +1,99 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package middleware + +import ( + "fmt" + "net/http" + "time" + + "codeberg.org/gruf/go-bytesize" + "codeberg.org/gruf/go-errors/v2" + "codeberg.org/gruf/go-kv" + "codeberg.org/gruf/go-logger/v2/level" + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +// Logger returns a gin middleware which provides request logging and panic recovery. +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + // Initialize the logging fields + fields := make(kv.Fields, 6, 7) + + // Determine pre-handler time + before := time.Now() + + // defer so that we log *after the request has completed* + defer func() { + code := c.Writer.Status() + path := c.Request.URL.Path + + if r := recover(); r != nil { + if c.Writer.Status() == 0 { + // No response was written, send a generic Internal Error + c.Writer.WriteHeader(http.StatusInternalServerError) + } + + // Append panic information to the request ctx + err := fmt.Errorf("recovered panic: %v", r) + _ = c.Error(err) + + // Dump a stacktrace to error log + callers := errors.GetCallers(3, 10) + log.WithField("stacktrace", callers).Error(err) + } + + // NOTE: + // It is very important here that we are ONLY logging + // the request path, and none of the query parameters. + // Query parameters can contain sensitive information + // and could lead to storing plaintext API keys in logs + + // Set request logging fields + fields[0] = kv.Field{"latency", time.Since(before)} + fields[1] = kv.Field{"clientIP", c.ClientIP()} + fields[2] = kv.Field{"userAgent", c.Request.UserAgent()} + fields[3] = kv.Field{"method", c.Request.Method} + fields[4] = kv.Field{"statusCode", code} + fields[5] = kv.Field{"path", path} + + // Create log entry with fields + l := log.WithFields(fields...) + + // Default is info + lvl := level.INFO + + if code >= 500 { + // This is a server error + lvl = level.ERROR + l = l.WithField("error", c.Errors) + } + + // Generate a nicer looking bytecount + size := bytesize.Size(c.Writer.Size()) + + // Finally, write log entry with status text body size + l.Logf(lvl, "%s: wrote %s", http.StatusText(code), size) + }() + + // Process request + c.Next() + } +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 000000000..ab947a124 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,77 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package middleware + +import ( + "net" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/ulule/limiter/v3" + limitergin "github.com/ulule/limiter/v3/drivers/middleware/gin" + "github.com/ulule/limiter/v3/drivers/store/memory" +) + +const rateLimitPeriod = 5 * time.Minute + +// RateLimit returns a gin middleware that will automatically rate limit caller (by IP address), +// and enrich the response header with the following headers: +// +// - `x-ratelimit-limit` - maximum number of requests allowed per time period (fixed). +// - `x-ratelimit-remaining` - number of remaining requests that can still be performed. +// - `x-ratelimit-reset` - unix timestamp when the rate limit will reset. +// +// If `x-ratelimit-limit` is exceeded, the request is aborted and an HTTP 429 TooManyRequests +// status is returned. +// +// If the config AdvancedRateLimitRequests value is <= 0, then a noop handler will be returned, +// which performs no rate limiting. +func RateLimit() gin.HandlerFunc { + // only enable rate limit middleware if configured + // advanced-rate-limit-requests is greater than 0 + rateLimitRequests := config.GetAdvancedRateLimitRequests() + if rateLimitRequests <= 0 { + // use noop middleware if ratelimiting is disabled + return func(c *gin.Context) {} + } + + rate := limiter.Rate{ + Period: rateLimitPeriod, + Limit: int64(rateLimitRequests), + } + + limiterInstance := limiter.New( + memory.NewStore(), + rate, + limiter.WithIPv6Mask(net.CIDRMask(64, 128)), // apply /64 mask to IPv6 addresses + ) + + limitReachedHandler := func(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit reached"}) + } + + middleware := limitergin.NewMiddleware( + limiterInstance, + limitergin.WithLimitReachedHandler(limitReachedHandler), // use custom rate limit reached error + ) + + return middleware +} diff --git a/internal/middleware/session.go b/internal/middleware/session.go new file mode 100644 index 000000000..bffbdcd92 --- /dev/null +++ b/internal/middleware/session.go @@ -0,0 +1,95 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package middleware + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/memstore" + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/log" + "golang.org/x/net/idna" +) + +// SessionOptions returns the standard set of options to use for each session. +func SessionOptions() sessions.Options { + var samesite http.SameSite + switch strings.TrimSpace(strings.ToLower(config.GetAdvancedCookiesSamesite())) { + case "lax": + samesite = http.SameSiteLaxMode + case "strict": + samesite = http.SameSiteStrictMode + default: + log.Warnf("%s set to %s which is not recognized, defaulting to 'lax'", config.AdvancedCookiesSamesiteFlag(), config.GetAdvancedCookiesSamesite()) + samesite = http.SameSiteLaxMode + } + + return sessions.Options{ + Path: "/", + Domain: config.GetHost(), + // 2 minutes + MaxAge: 120, + // only set secure over https + Secure: config.GetProtocol() == "https", + // forbid javascript from inspecting cookie + HttpOnly: true, + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-same-site-00#section-4.1.1 + SameSite: samesite, + } +} + +// SessionName is a utility function that derives an appropriate session name from the hostname. +func SessionName() (string, error) { + // parse the protocol + host + protocol := config.GetProtocol() + host := config.GetHost() + u, err := url.Parse(fmt.Sprintf("%s://%s", protocol, host)) + if err != nil { + return "", err + } + + // take the hostname without any port attached + strippedHostname := u.Hostname() + if strippedHostname == "" { + return "", fmt.Errorf("could not derive hostname without port from %s://%s", protocol, host) + } + + // make sure IDNs are converted to punycode or the cookie library breaks: + // see https://en.wikipedia.org/wiki/Punycode + punyHostname, err := idna.New().ToASCII(strippedHostname) + if err != nil { + return "", fmt.Errorf("could not convert %s to punycode: %s", strippedHostname, err) + } + + return fmt.Sprintf("gotosocial-%s", punyHostname), nil +} + +// Session returns a new gin middleware that implements session cookies using the given +// sessionName, authentication key, and encryption key. Session name can be derived from the +// SessionName utility function in this package. +func Session(sessionName string, auth []byte, crypt []byte) gin.HandlerFunc { + store := memstore.NewStore(auth, crypt) + store.Options(SessionOptions()) + return sessions.Sessions(sessionName, store) +} diff --git a/internal/middleware/session_test.go b/internal/middleware/session_test.go new file mode 100644 index 000000000..d11d8c202 --- /dev/null +++ b/internal/middleware/session_test.go @@ -0,0 +1,95 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package middleware_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type SessionTestSuite struct { + suite.Suite +} + +func (suite *SessionTestSuite) SetupTest() { + testrig.InitTestConfig() +} + +func (suite *SessionTestSuite) TestDeriveSessionNameLocalhostWithPort() { + config.SetProtocol("http") + config.SetHost("localhost:8080") + + sessionName, err := middleware.SessionName() + suite.NoError(err) + suite.Equal("gotosocial-localhost", sessionName) +} + +func (suite *SessionTestSuite) TestDeriveSessionNameLocalhost() { + config.SetProtocol("http") + config.SetHost("localhost") + + sessionName, err := middleware.SessionName() + suite.NoError(err) + suite.Equal("gotosocial-localhost", sessionName) +} + +func (suite *SessionTestSuite) TestDeriveSessionNoProtocol() { + config.SetProtocol("") + config.SetHost("localhost") + + sessionName, err := middleware.SessionName() + suite.EqualError(err, "parse \"://localhost\": missing protocol scheme") + suite.Equal("", sessionName) +} + +func (suite *SessionTestSuite) TestDeriveSessionNoHost() { + config.SetProtocol("https") + config.SetHost("") + config.SetPort(0) + + sessionName, err := middleware.SessionName() + suite.EqualError(err, "could not derive hostname without port from https://") + suite.Equal("", sessionName) +} + +func (suite *SessionTestSuite) TestDeriveSessionOK() { + config.SetProtocol("https") + config.SetHost("example.org") + + sessionName, err := middleware.SessionName() + suite.NoError(err) + suite.Equal("gotosocial-example.org", sessionName) +} + +func (suite *SessionTestSuite) TestDeriveSessionIDNOK() { + config.SetProtocol("https") + config.SetHost("fóid.org") + + sessionName, err := middleware.SessionName() + suite.NoError(err) + suite.Equal("gotosocial-xn--fid-gna.org", sessionName) +} + +func TestSessionTestSuite(t *testing.T) { + suite.Run(t, &SessionTestSuite{}) +} diff --git a/internal/middleware/signaturecheck.go b/internal/middleware/signaturecheck.go new file mode 100644 index 000000000..c1f190eb5 --- /dev/null +++ b/internal/middleware/signaturecheck.go @@ -0,0 +1,93 @@ +package middleware + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/log" + + "github.com/gin-gonic/gin" + "github.com/go-fed/httpsig" +) + +var ( + // this mimics an untyped error returned by httpsig when no signature is present; + // define it here so that we can use it to decide what to log without hitting + // performance too hard + noSignatureError = fmt.Sprintf("neither %q nor %q have signature parameters", httpsig.Signature, httpsig.Authorization) + signatureHeader = string(httpsig.Signature) + authorizationHeader = string(httpsig.Authorization) +) + +// SignatureCheck returns a gin middleware for checking http signatures. +// +// The middleware first checks whether an incoming http request has been http-signed with a well-formed signature. +// +// If so, it will check if the domain that signed the request is permitted to access the server, using the provided isURIBlocked function. +// +// If it is permitted, the handler will set the key verifier and the signature in the gin context for use down the line. +// +// If the domain is blocked, the middleware will abort the request chain instead with http code 403 forbidden. +// +// In case of an error, the request will be aborted with http code 500 internal server error. +func SignatureCheck(isURIBlocked func(context.Context, *url.URL) (bool, db.Error)) func(*gin.Context) { + return func(c *gin.Context) { + // create the verifier from the request, this will error if the request wasn't signed + verifier, err := httpsig.NewVerifier(c.Request) + if err != nil { + // Something went wrong, so we need to return regardless, but only actually + // *abort* the request with 401 if a signature was present but malformed + if err.Error() != noSignatureError { + log.Debugf("http signature was present but invalid: %s", err) + c.AbortWithStatus(http.StatusUnauthorized) + } + return + } + + // The request was signed! + // The key ID should be given in the signature so that we know where to fetch it from the remote server. + // This will be something like https://example.org/users/whatever_requesting_user#main-key + requestingPublicKeyIDString := verifier.KeyId() + requestingPublicKeyID, err := url.Parse(requestingPublicKeyIDString) + if err != nil { + log.Debugf("http signature requesting public key id %s could not be parsed as a url: %s", requestingPublicKeyIDString, err) + c.AbortWithStatus(http.StatusUnauthorized) + return + } else if requestingPublicKeyID == nil { + // Key can sometimes be nil, according to url parse function: + // 'Trying to parse a hostname and path without a scheme is invalid but may not necessarily return an error, due to parsing ambiguities' + log.Debugf("http signature requesting public key id %s was nil after parsing as a url", requestingPublicKeyIDString) + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + // we managed to parse the url! + // if the domain is blocked we want to bail as early as possible + if blocked, err := isURIBlocked(c.Request.Context(), requestingPublicKeyID); err != nil { + log.Errorf("could not tell if domain %s was blocked or not: %s", requestingPublicKeyID.Host, err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } else if blocked { + log.Infof("domain %s is blocked", requestingPublicKeyID.Host) + c.AbortWithStatus(http.StatusForbidden) + return + } + + // assume signature was set on Signature header (most common behavior), + // but fall back to Authorization header if necessary + var signature string + if s := c.GetHeader(signatureHeader); s != "" { + signature = s + } else { + signature = c.GetHeader(authorizationHeader) + } + + // set the verifier and signature on the context here to save some work further down the line + c.Set(string(ap.ContextRequestingPublicKeyVerifier), verifier) + c.Set(string(ap.ContextRequestingPublicKeySignature), signature) + } +} diff --git a/internal/middleware/tokencheck.go b/internal/middleware/tokencheck.go new file mode 100644 index 000000000..ca93f379a --- /dev/null +++ b/internal/middleware/tokencheck.go @@ -0,0 +1,140 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/oauth2/v4" +) + +// TokenCheck returns a new gin middleware for validating oauth tokens in requests. +// +// The middleware checks the request Authorization header for a valid oauth Bearer token. +// +// If no token was set in the Authorization header, or the token was invalid, the handler will return. +// +// If a valid oauth Bearer token was provided, it will be set on the gin context for further use. +// +// Then, it will check which *gtsmodel.User the token belongs to. If the user is not confirmed, not approved, +// or has been disabled, then the middleware will return early. Otherwise, the User will be set on the +// gin context for further processing by other functions. +// +// Next, it will look up the *gtsmodel.Account for the User. If the Account has been suspended, then the +// middleware will return early. Otherwise, it will set the Account on the gin context too. +// +// Finally, it will check the client ID of the token to see if a *gtsmodel.Application can be retrieved +// for that client ID. This will also be set on the gin context. +// +// If an invalid token is presented, or a user/account/application can't be found, then this middleware +// won't abort the request, since the server might want to still allow public requests that don't have a +// Bearer token set (eg., for public instance information and so on). +func TokenCheck(dbConn db.DB, validateBearerToken func(r *http.Request) (oauth2.TokenInfo, error)) func(*gin.Context) { + return func(c *gin.Context) { + ctx := c.Request.Context() + + if c.Request.Header.Get("Authorization") == "" { + // no token set in the header, we can just bail + return + } + + ti, err := validateBearerToken(c.Copy().Request) + if err != nil { + log.Debugf("token was passed in Authorization header but we could not validate it: %s", err) + return + } + c.Set(oauth.SessionAuthorizedToken, ti) + + // check for user-level token + if userID := ti.GetUserID(); userID != "" { + log.Tracef("authenticated user %s with bearer token, scope is %s", userID, ti.GetScope()) + + // fetch user for this token + user, err := dbConn.GetUserByID(ctx, userID) + if err != nil { + if err != db.ErrNoEntries { + log.Errorf("database error looking for user with id %s: %s", userID, err) + return + } + log.Warnf("no user found for userID %s", userID) + return + } + + if user.ConfirmedAt.IsZero() { + log.Warnf("authenticated user %s has never confirmed thier email address", userID) + return + } + + if !*user.Approved { + log.Warnf("authenticated user %s's account was never approved by an admin", userID) + return + } + + if *user.Disabled { + log.Warnf("authenticated user %s's account was disabled'", userID) + return + } + + c.Set(oauth.SessionAuthorizedUser, user) + + // fetch account for this token + if user.Account == nil { + acct, err := dbConn.GetAccountByID(ctx, user.AccountID) + if err != nil { + if err != db.ErrNoEntries { + log.Errorf("database error looking for account with id %s: %s", user.AccountID, err) + return + } + log.Warnf("no account found for userID %s", userID) + return + } + user.Account = acct + } + + if !user.Account.SuspendedAt.IsZero() { + log.Warnf("authenticated user %s's account (accountId=%s) has been suspended", userID, user.AccountID) + return + } + + c.Set(oauth.SessionAuthorizedAccount, user.Account) + } + + // check for application token + if clientID := ti.GetClientID(); clientID != "" { + log.Tracef("authenticated client %s with bearer token, scope is %s", clientID, ti.GetScope()) + + // fetch app for this token + app := >smodel.Application{} + if err := dbConn.GetWhere(ctx, []db.Where{{Key: "client_id", Value: clientID}}, app); err != nil { + if err != db.ErrNoEntries { + log.Errorf("database error looking for application with clientID %s: %s", clientID, err) + return + } + log.Warnf("no app found for client %s", clientID) + return + } + c.Set(oauth.SessionAuthorizedApplication, app) + } + } +} diff --git a/internal/middleware/useragent.go b/internal/middleware/useragent.go new file mode 100644 index 000000000..e55a19434 --- /dev/null +++ b/internal/middleware/useragent.go @@ -0,0 +1,39 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package middleware + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// UserAgent returns a gin middleware which aborts requests with +// empty user agent strings, returning code 418 - I'm a teapot. +func UserAgent() gin.HandlerFunc { + // todo: make this configurable + return func(c *gin.Context) { + if ua := c.Request.UserAgent(); ua == "" { + code := http.StatusTeapot + err := errors.New(http.StatusText(code) + ": no user-agent sent with request") + c.AbortWithStatusJSON(code, gin.H{"error": err.Error()}) + } + } +} |