diff --git a/HISTORY.md b/HISTORY.md index 5d0fba48..8641e21b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -372,6 +372,8 @@ Other Improvements: ![DBUG routes](https://iris-go.com/images/v12.2.0-dbug2.png?v=0) - Enhanced cookie security and management through new `Context.AddCookieOptions` method and new cookie options (look on New Package-level functions section below), [securecookie](https://github.com/kataras/iris/tree/master/_examples/cookies/securecookie) example has been updated. +- `Context.RemoveCookie` removes also the Request's cookies of the same request lifecycle when `iris.CookieAllowReclaim` is set to cookie options, [example](https://github.com/kataras/iris/tree/master/_examples/cookies/options). + - `iris.TLS` can now accept certificates as raw contents too. - `iris.TLS` registers a secondary http server which redirects "http://" to their "https://" equivalent requests, unless the new `iris.TLSNoRedirect` host Configurator is provided on `iris.TLS` (or `iris.AutoTLS`), e.g. `app.Run(iris.TLS("127.0.0.1:443", "mycert.cert", "mykey.key", iris.TLSNoRedirect))`. @@ -405,6 +407,7 @@ New Package-level Variables: New Context Methods: +- `Context.GetDomain() string` returns the domain. - `Context.AddCookieOptions(...CookieOption)` adds options for `SetCookie`, `SetCookieKV, UpsertCookie` and `RemoveCookie` methods for the current request. - `Context.ClearCookieOptions()` clears any cookie options registered through `AddCookieOptions`. - `Context.SetVersion(constraint string)` force-sets an [API Version](https://github.com/kataras/iris/wiki/API-versioning) @@ -423,7 +426,6 @@ New Context Methods: - `Context.ReadProtobuf(ptr)` binds request body to a proto message - `Context.ReadMsgPack(ptr)` binds request body of a msgpack format to a struct - `Context.ReadBody(ptr)` binds the request body to the "ptr" depending on the request's Method and Content-Type -- `Context.SetSameSite(http.SameSite)` to set cookie "SameSite" option (respectful by sessions too) - `Context.Defer(Handler)` works like `Party.Done` but for the request life-cycle instead - `Context.ReflectValue() []reflect.Value` stores and returns the `[]reflect.ValueOf(ctx)` - `Context.Controller() reflect.Value` returns the current MVC Controller value. diff --git a/_examples/README.md b/_examples/README.md index 7fe15ea4..c0b10ba3 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -137,6 +137,7 @@ * [Manage Permissions](permissions/main.go) * Cookies * [Basic](cookies/basic/main.go) + * [Options](cookies/options/main.go) * [Encode/Decode (with `securecookie`)](cookies/securecookie/main.go) * Sessions * [Overview: Config](sessions/overview/main.go) diff --git a/_examples/cookies/options/main.go b/_examples/cookies/options/main.go new file mode 100644 index 00000000..70d7e272 --- /dev/null +++ b/_examples/cookies/options/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "github.com/kataras/iris/v12" +) + +func main() { + app := newApp() + + // http://localhost:8080/set/name1/value1 + // http://localhost:8080/get/name1 + // http://localhost:8080/remove/name1 + app.Listen(":8080", iris.WithLogLevel("debug")) +} + +func newApp() *iris.Application { + app := iris.New() + app.Use(withCookieOptions) + + app.Get("/set/{name}/{value}", setCookie) + app.Get("/get/{name}", getCookie) + app.Get("/remove/{name}", removeCookie) + + return app +} + +func withCookieOptions(ctx iris.Context) { + // Register cookie options for request-lifecycle. + // To register per cookie, just add the CookieOption + // on the last variadic input argument of + // SetCookie, SetCookieKV, UpsertCookie, RemoveCookie + // and GetCookie Context methods. + // + // * CookieAllowReclaim + // * CookieAllowSubdomains + // * CookieSecure + // * CookieHTTPOnly + // * CookieSameSite + // * CookiePath + // * CookieCleanPath + // * CookieExpires + // * CookieEncoding + ctx.AddCookieOptions(iris.CookieAllowReclaim()) + ctx.Next() +} + +func setCookie(ctx iris.Context) { + name := ctx.Params().Get("name") + value := ctx.Params().Get("value") + + ctx.SetCookieKV(name, value) + + // By-default net/http does not remove or set the Cookie on the Request object. + // + // With the `CookieAllowReclaim` option, whenever you set or remove a cookie + // it will be also reflected in the Request object immediately (of the same request lifecycle) + // therefore, any of the next handlers in the chain are not holding the old value. + valueIsAvailableInRequestObject := ctx.GetCookie(name) + ctx.Writef("cookie %s=%s", name, valueIsAvailableInRequestObject) +} + +func getCookie(ctx iris.Context) { + name := ctx.Params().Get("name") + + value := ctx.GetCookie(name) + ctx.WriteString(value) +} + +func removeCookie(ctx iris.Context) { + name := ctx.Params().Get("name") + + ctx.RemoveCookie(name) + + removedFromRequestObject := ctx.GetCookie(name) // CookieAllowReclaim feature. + ctx.Writef("cookie %s removed, value should be empty=%s", name, removedFromRequestObject) +} diff --git a/_examples/cookies/options/main_test.go b/_examples/cookies/options/main_test.go new file mode 100644 index 00000000..7f064d46 --- /dev/null +++ b/_examples/cookies/options/main_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/kataras/iris/v12/httptest" +) + +func TestCookieOptions(t *testing.T) { + app := newApp() + e := httptest.New(t, app, httptest.URL("http://example.com")) + + cookieName, cookieValue := "my_cookie_name", "my_cookie_value" + + // Test set a Cookie. + t1 := e.GET(fmt.Sprintf("/set/%s/%s", cookieName, cookieValue)).Expect().Status(httptest.StatusOK) + t1.Cookie(cookieName).Value().Equal(cookieValue) + t1.Body().Contains(fmt.Sprintf("%s=%s", cookieName, cookieValue)) + + // Test retrieve a Cookie. + t2 := e.GET(fmt.Sprintf("/get/%s", cookieName)).Expect().Status(httptest.StatusOK) + t2.Body().Equal(cookieValue) + + // Test remove a Cookie. + t3 := e.GET(fmt.Sprintf("/remove/%s", cookieName)).Expect().Status(httptest.StatusOK) + t3.Body().Contains(fmt.Sprintf("cookie %s removed, value should be empty=%s", cookieName, "")) + + t4 := e.GET(fmt.Sprintf("/get/%s", cookieName)).Expect().Status(httptest.StatusOK) + t4.Cookies().Empty() + t4.Body().Empty() +} diff --git a/context/context.go b/context/context.go index d5607132..aa365f93 100644 --- a/context/context.go +++ b/context/context.go @@ -378,6 +378,8 @@ type Context interface { RemoteAddr() string // GetHeader returns the request header's value based on its name. GetHeader(name string) string + // GetDomain resolves and returns the server's domain. + GetDomain() string // IsAjax returns true if this request is an 'ajax request'( XMLHttpRequest) // // There is no a 100% way of knowing that a request was made via Ajax. @@ -992,12 +994,15 @@ type Context interface { // See `ClearCookieOptions` too. // // Available builtin Cookie options are: - // * CookieSameSite - // * CookiePath - // * CookieCleanPath - // * CookieExpires - // * CookieHTTPOnly - // * CookieEncoding + // * CookieAllowReclaim + // * CookieAllowSubdomains + // * CookieSecure + // * CookieHTTPOnly + // * CookieSameSite + // * CookiePath + // * CookieCleanPath + // * CookieExpires + // * CookieEncoding // // Example at: https://github.com/kataras/iris/tree/master/_examples/cookies/securecookie AddCookieOptions(options ...CookieOption) @@ -1878,6 +1883,20 @@ func (ctx *context) GetHeader(name string) string { return ctx.request.Header.Get(name) } +// GetDomain resolves and returns the server's domain. +func (ctx *context) GetDomain() string { + host := ctx.Host() + if portIdx := strings.IndexByte(host, ':'); portIdx > 0 { + host = host[0:portIdx] + } + + if domain, err := publicsuffix.EffectiveTLDPlusOne(host); err == nil { + host = domain + } + + return host +} + // IsAjax returns true if this request is an 'ajax request'( XMLHttpRequest) // // There is no a 100% way of knowing that a request was made via Ajax. @@ -4755,18 +4774,16 @@ const ( // CookieOption is the type of function that is accepted on // context's methods like `SetCookieKV`, `RemoveCookie` and `SetCookie` -// as their (last) variadic input argument to amend the end cookie's form. +// as their (last) variadic input argument to amend the to-be-sent cookie. // -// Any custom or builtin `CookieOption` is valid, -// see `CookiePath`, `CookieCleanPath`, `CookieExpires` and `CookieHTTPOnly` for more. // The "op" is the operation code, 0 is GET, 1 is SET and 2 is REMOVE. -type CookieOption func(c *http.Cookie, op uint8) +type CookieOption func(ctx Context, c *http.Cookie, op uint8) -// findCookieAgainst reports whether the "cookie.Name" is in the list of "cookieNames". +// CookieIncluded reports whether the "cookie.Name" is in the list of "cookieNames". // Notes: // If "cookieNames" slice is empty then it returns true, // If "cookie.Name" is empty then it returns false. -func findCookieAgainst(cookie *http.Cookie, cookieNames []string) bool { +func CookieIncluded(cookie *http.Cookie, cookieNames []string) bool { if cookie.Name == "" { return false } @@ -4784,25 +4801,53 @@ func findCookieAgainst(cookie *http.Cookie, cookieNames []string) bool { return true } +var cookieNameSanitizer = strings.NewReplacer("\n", "-", "\r", "-") + +func sanitizeCookieName(n string) string { + return cookieNameSanitizer.Replace(n) +} + // CookieAllowReclaim accepts the Context itself. // If set it will add the cookie to (on `CookieSet`, `CookieSetKV`, `CookieUpsert`) // or remove the cookie from (on `CookieRemove`) the Request object too. -func CookieAllowReclaim(ctx Context, cookieNames ...string) CookieOption { - return func(c *http.Cookie, op uint8) { +func CookieAllowReclaim(cookieNames ...string) CookieOption { + return func(ctx Context, c *http.Cookie, op uint8) { if op == OpCookieGet { return } - if !findCookieAgainst(c, cookieNames) { + if !CookieIncluded(c, cookieNames) { return } switch op { case OpCookieSet: + // perform upsert on request cookies or is it too much and not worth the cost? ctx.Request().AddCookie(c) case OpCookieDel: - // TODO: delete only this c.Name. - ctx.Request().Header.Set("Cookie", "") + header := ctx.Request().Header + + if cookiesLine := header.Get("Cookie"); cookiesLine != "" { + if cookies := strings.Split(cookiesLine, "; "); len(cookies) > 1 { + // more than one cookie here. + // select that one and remove it. + name := sanitizeCookieName(c.Name) + + for _, nameValue := range cookies { + if strings.HasPrefix(nameValue, name) { + cookiesLine = strings.Replace(cookiesLine, "; "+nameValue, "", 1) + // current cookiesLine: myapp_session_id=5ccf4e89-8d0e-4ed6-9f4c-6746d7c5e2ee; key1=value1 + // found nameValue: key1=value1 + // new cookiesLine: myapp_session_id=5ccf4e89-8d0e-4ed6-9f4c-6746d7c5e2ee + header.Set("Cookie", cookiesLine) + break + } + } + return + } + } + + header.Del("Cookie") } } @@ -4812,28 +4857,17 @@ func CookieAllowReclaim(ctx Context, cookieNames ...string) CookieOption { // in order to allow subdomains to have access to the cookies. // It sets the cookie's Domain field (if was empty) and // it also sets the cookie's SameSite to lax mode too. -func CookieAllowSubdomains(ctx Context, cookieNames ...string) CookieOption { - host := ctx.Host() - if portIdx := strings.IndexByte(host, ':'); portIdx > 0 { - host = host[0:portIdx] - } - - cookieDomain := "." + host - - if domain, err := publicsuffix.EffectiveTLDPlusOne(host); err == nil { - cookieDomain = "." + domain - } - - return func(c *http.Cookie, _ uint8) { +func CookieAllowSubdomains(cookieNames ...string) CookieOption { + return func(ctx Context, c *http.Cookie, _ uint8) { if c.Domain != "" { return // already set. } - if !findCookieAgainst(c, cookieNames) { + if !CookieIncluded(c, cookieNames) { return } - c.Domain = cookieDomain + c.Domain = ctx.GetDomain() c.SameSite = http.SameSiteLaxMode // allow subdomain sharing. } } @@ -4846,7 +4880,7 @@ func CookieAllowSubdomains(ctx Context, cookieNames ...string) CookieOption { // // See https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 for details. func CookieSameSite(sameSite http.SameSite) CookieOption { - return func(c *http.Cookie, op uint8) { + return func(_ Context, c *http.Cookie, op uint8) { if op == OpCookieSet { c.SameSite = sameSite } @@ -4855,12 +4889,10 @@ func CookieSameSite(sameSite http.SameSite) CookieOption { // CookieSecure sets the cookie's Secure option if the current request's // connection is using TLS. See `CookieHTTPOnly` too. -func CookieSecure(ctx Context) CookieOption { - return func(c *http.Cookie, op uint8) { - if op == OpCookieSet { - if ctx.Request().TLS != nil { - c.Secure = true - } +func CookieSecure(ctx Context, c *http.Cookie, op uint8) { + if op == OpCookieSet { + if ctx.Request().TLS != nil { + c.Secure = true } } } @@ -4870,7 +4902,7 @@ func CookieSecure(ctx Context) CookieOption { // HttpOnly field defaults to true for `RemoveCookie` and `SetCookieKV`. // See `CookieSecure` too. func CookieHTTPOnly(httpOnly bool) CookieOption { - return func(c *http.Cookie, op uint8) { + return func(_ Context, c *http.Cookie, op uint8) { if op == OpCookieSet { c.HttpOnly = httpOnly } @@ -4880,7 +4912,7 @@ func CookieHTTPOnly(httpOnly bool) CookieOption { // CookiePath is a `CookieOption`. // Use it to change the cookie's Path field. func CookiePath(path string) CookieOption { - return func(c *http.Cookie, op uint8) { + return func(_ Context, c *http.Cookie, op uint8) { if op > OpCookieGet { // on set and remove. c.Path = path } @@ -4889,7 +4921,7 @@ func CookiePath(path string) CookieOption { // CookieCleanPath is a `CookieOption`. // Use it to clear the cookie's Path field, exactly the same as `CookiePath("")`. -func CookieCleanPath(c *http.Cookie, op uint8) { +func CookieCleanPath(_ Context, c *http.Cookie, op uint8) { if op > OpCookieGet { c.Path = "" } @@ -4898,7 +4930,7 @@ func CookieCleanPath(c *http.Cookie, op uint8) { // CookieExpires is a `CookieOption`. // Use it to change the cookie's Expires and MaxAge fields by passing the lifetime of the cookie. func CookieExpires(durFromNow time.Duration) CookieOption { - return func(c *http.Cookie, op uint8) { + return func(_ Context, c *http.Cookie, op uint8) { if op == OpCookieSet { c.Expires = time.Now().Add(durFromNow) c.MaxAge = int(durFromNow.Seconds()) @@ -4945,12 +4977,12 @@ type SecureCookie interface { // // Example: https://github.com/kataras/iris/tree/master/_examples/cookies/securecookie func CookieEncoding(encoding SecureCookie, cookieNames ...string) CookieOption { - return func(c *http.Cookie, op uint8) { + return func(_ Context, c *http.Cookie, op uint8) { if op == OpCookieDel { return } - if !findCookieAgainst(c, cookieNames) { + if !CookieIncluded(c, cookieNames) { return } @@ -5010,7 +5042,7 @@ func (ctx *context) applyCookieOptions(c *http.Cookie, op uint8, override []Cook if v := ctx.Values().Get(cookieOptionsContextKey); v != nil { if options, ok := v.([]CookieOption); ok { for _, opt := range options { - opt(c, op) + opt(ctx, c, op) } } } @@ -5018,7 +5050,7 @@ func (ctx *context) applyCookieOptions(c *http.Cookie, op uint8, override []Cook // The function's ones should be called last, so they can override // the stored ones (i.e by a prior middleware). for _, opt := range override { - opt(c, op) + opt(ctx, c, op) } } diff --git a/sessions/sessions.go b/sessions/sessions.go index bcaf53d7..896031fe 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -104,20 +104,21 @@ const sessionContextKey = "iris.session" func (s *Sessions) Handler(cookieOptions ...context.CookieOption) context.Handler { s.handlerCookieOpts = cookieOptions + var requestOptions []context.CookieOption + if s.config.AllowReclaim { + requestOptions = append(requestOptions, context.CookieAllowReclaim(s.config.Cookie)) + } + if !s.config.DisableSubdomainPersistence { + requestOptions = append(requestOptions, context.CookieAllowSubdomains(s.config.Cookie)) + } + if s.config.CookieSecureTLS { + requestOptions = append(requestOptions, context.CookieSecure) + } + if s.config.Encoding != nil { + requestOptions = append(requestOptions, context.CookieEncoding(s.config.Encoding, s.config.Cookie)) + } + return func(ctx context.Context) { - var requestOptions []context.CookieOption - if s.config.AllowReclaim { - requestOptions = append(requestOptions, context.CookieAllowReclaim(ctx, s.config.Cookie)) - } - if !s.config.DisableSubdomainPersistence { - requestOptions = append(requestOptions, context.CookieAllowSubdomains(ctx, s.config.Cookie)) - } - if s.config.CookieSecureTLS { - requestOptions = append(requestOptions, context.CookieSecure(ctx)) - } - if s.config.Encoding != nil { - requestOptions = append(requestOptions, context.CookieEncoding(s.config.Encoding, s.config.Cookie)) - } ctx.AddCookieOptions(requestOptions...) // request life-cycle options. session := s.Start(ctx, cookieOptions...) // this cookie's end-developer's custom options.