diff --git a/cmd/whawty-nginx-sso/config.go b/cmd/whawty-nginx-sso/config.go index 42c7bf7..6feaaa3 100644 --- a/cmd/whawty-nginx-sso/config.go +++ b/cmd/whawty-nginx-sso/config.go @@ -35,6 +35,7 @@ import ( "os" "github.com/spreadspace/tlsconfig" + "github.com/whawty/nginx-sso/cookie" "gopkg.in/yaml.v3" ) @@ -43,7 +44,8 @@ type WebConfig struct { } type Config struct { - Web WebConfig `yaml:"web"` + Web WebConfig `yaml:"web"` + Cookie cookie.Config `yaml:"cookie"` } func readConfig(configfile string) (*Config, error) { diff --git a/cmd/whawty-nginx-sso/main.go b/cmd/whawty-nginx-sso/main.go index 84eccf2..53ec5d9 100644 --- a/cmd/whawty-nginx-sso/main.go +++ b/cmd/whawty-nginx-sso/main.go @@ -38,6 +38,7 @@ import ( "sync" "github.com/urfave/cli" + "github.com/whawty/nginx-sso/cookie" ) var ( @@ -57,6 +58,11 @@ func cmdRun(c *cli.Context) error { return cli.NewExitError(err.Error(), 1) } + cookies, err := cookie.NewController(&conf.Cookie) + if err != nil { + return cli.NewExitError(err.Error(), 2) + } + webAddrs := c.StringSlice("web-addr") var wg sync.WaitGroup for _, webAddr := range webAddrs { @@ -64,7 +70,7 @@ func cmdRun(c *cli.Context) error { wg.Add(1) go func() { defer wg.Done() - if err := runWebAddr(a, &conf.Web); err != nil { + if err := runWebAddr(a, &conf.Web, cookies); err != nil { fmt.Printf("warning running web interface(%s) failed: %s\n", a, err) } }() diff --git a/cmd/whawty-nginx-sso/web.go b/cmd/whawty-nginx-sso/web.go index c4f1a2d..594f2fd 100644 --- a/cmd/whawty-nginx-sso/web.go +++ b/cmd/whawty-nginx-sso/web.go @@ -37,6 +37,7 @@ import ( "github.com/flosch/pongo2/v6" "github.com/gin-gonic/gin" + "github.com/whawty/nginx-sso/cookie" "github.com/whawty/nginx-sso/ui" "gitlab.com/go-box/pongo2gin/v6" ) @@ -78,7 +79,7 @@ func webHandleLogout(c *gin.Context) { c.Status(http.StatusNotImplemented) } -func runWeb(listener net.Listener, config *WebConfig) (err error) { +func runWeb(listener net.Listener, config *WebConfig, cookies *cookie.Controller) (err error) { gin.SetMode(gin.ReleaseMode) r := gin.New() @@ -109,7 +110,7 @@ func runWeb(listener net.Listener, config *WebConfig) (err error) { return server.Serve(listener) } -func runWebAddr(addr string, config *WebConfig) (err error) { +func runWebAddr(addr string, config *WebConfig, cookies *cookie.Controller) (err error) { if addr == "" { addr = ":http" } @@ -117,9 +118,9 @@ func runWebAddr(addr string, config *WebConfig) (err error) { if err != nil { return err } - return runWeb(ln.(*net.TCPListener), config) + return runWeb(ln.(*net.TCPListener), config, cookies) } -func runWebListener(listener *net.TCPListener, config *WebConfig) (err error) { - return runWeb(listener, config) +func runWebListener(listener *net.TCPListener, config *WebConfig, cookies *cookie.Controller) (err error) { + return runWeb(listener, config, cookies) } diff --git a/contrib/sample-cfg.yml b/contrib/sample-cfg.yml index 0511ab8..93b78c2 100644 --- a/contrib/sample-cfg.yml +++ b/contrib/sample-cfg.yml @@ -1,40 +1,58 @@ -web: - # tls: - # certificate: "/path/to/server-crt.pem" - # certificate-key: "/path/to/server-key.pem" - # min-protocol-version: "TLSv1.2" - # # max-protocol-version: "TLSv1.3" - # ciphers: - # # - RSA_WITH_RC4_128_SHA - # # - RSA_WITH_3DES_EDE_CBC_SHA - # # - RSA_WITH_AES_128_CBC_SHA - # # - RSA_WITH_AES_256_CBC_SHA - # # - RSA_WITH_AES_128_CBC_SHA256 - # # - RSA_WITH_AES_128_GCM_SHA256 - # # - RSA_WITH_AES_256_GCM_SHA384 - # # - ECDHE_ECDSA_WITH_RC4_128_SHA - # # - ECDHE_ECDSA_WITH_AES_128_CBC_SHA - # # - ECDHE_ECDSA_WITH_AES_256_CBC_SHA - # # - ECDHE_RSA_WITH_RC4_128_SHA - # # - ECDHE_RSA_WITH_3DES_EDE_CBC_SHA - # # - ECDHE_RSA_WITH_AES_128_CBC_SHA - # # - ECDHE_RSA_WITH_AES_256_CBC_SHA - # # - ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 - # # - ECDHE_RSA_WITH_AES_128_CBC_SHA256 - # - ECDHE_RSA_WITH_AES_128_GCM_SHA256 - # # - ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 - # - ECDHE_RSA_WITH_AES_256_GCM_SHA384 - # # - ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 - # - ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 - # # - ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 - # - TLS_AES_128_GCM_SHA256 - # - TLS_AES_256_GCM_SHA384 - # - TLS_CHACHA20_POLY1305_SHA256 - # prefer-server-ciphers: true - # # ecdh-curves: - # # - secp256r1 - # # - secp384r1 - # # - secp521r1 - # # - x25519 - # # session-tickets: true - # # session-ticket-key: "b947e39f50e20351bdd81046e20fff7948d359a3aec391719d60645c5972cc77" +cookie: + domain: example.com + name: __Secure-whawty-nignx-sso + secure: true + expire: 23h + signers: + - name: foo + ed25519: + ## generate with `openssl genpkey -algorithm ED25519` + key: | + -----BEGIN PRIVATE KEY----- + MC4CAQAwBQYDK2VwBCIEIIgITVt9BRor5Dn2v7rQu2I8siicIUGr7+QS9PqNSSXk + -----END PRIVATE KEY----- + # - name: bar + # ed25519: + # ## generate with `openssl genpkey -algorithm ED25519 -out ./contrib/bar_ed25519.pem` + # key_file: ./contrib/bar_ed25519.pem + +# web: +# tls: +# certificate: "/path/to/server-crt.pem" +# certificate-key: "/path/to/server-key.pem" +# min-protocol-version: "TLSv1.2" +# # max-protocol-version: "TLSv1.3" +# ciphers: +# # - RSA_WITH_RC4_128_SHA +# # - RSA_WITH_3DES_EDE_CBC_SHA +# # - RSA_WITH_AES_128_CBC_SHA +# # - RSA_WITH_AES_256_CBC_SHA +# # - RSA_WITH_AES_128_CBC_SHA256 +# # - RSA_WITH_AES_128_GCM_SHA256 +# # - RSA_WITH_AES_256_GCM_SHA384 +# # - ECDHE_ECDSA_WITH_RC4_128_SHA +# # - ECDHE_ECDSA_WITH_AES_128_CBC_SHA +# # - ECDHE_ECDSA_WITH_AES_256_CBC_SHA +# # - ECDHE_RSA_WITH_RC4_128_SHA +# # - ECDHE_RSA_WITH_3DES_EDE_CBC_SHA +# # - ECDHE_RSA_WITH_AES_128_CBC_SHA +# # - ECDHE_RSA_WITH_AES_256_CBC_SHA +# # - ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 +# # - ECDHE_RSA_WITH_AES_128_CBC_SHA256 +# - ECDHE_RSA_WITH_AES_128_GCM_SHA256 +# # - ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 +# - ECDHE_RSA_WITH_AES_256_GCM_SHA384 +# # - ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 +# - ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 +# # - ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 +# - TLS_AES_128_GCM_SHA256 +# - TLS_AES_256_GCM_SHA384 +# - TLS_CHACHA20_POLY1305_SHA256 +# prefer-server-ciphers: true +# # ecdh-curves: +# # - secp256r1 +# # - secp384r1 +# # - secp521r1 +# # - x25519 +# # session-tickets: true +# # session-ticket-key: "b947e39f50e20351bdd81046e20fff7948d359a3aec391719d60645c5972cc77" diff --git a/cookie/controller.go b/cookie/controller.go new file mode 100644 index 0000000..37f9d52 --- /dev/null +++ b/cookie/controller.go @@ -0,0 +1,131 @@ +// +// Copyright (c) 2023 whawty contributors (see AUTHORS file) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// * Neither the name of whawty.nginx-sso nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// + +package cookie + +import ( + "fmt" + "time" +) + +const ( + DefaultExpire = 24 * time.Hour +) + +type SignerConfig struct { + Name string `yaml:"name"` + Ed25519 *Ed25519Config `yaml:"ed25519"` +} + +type Config struct { + Domain string `yaml:"domain"` + Name string `yaml:"name"` + Secure bool `yaml:"secure"` + Expire time.Duration `yaml:"expire"` + Signers []SignerConfig `yaml:"signers"` +} + +type Signer interface { + Sign(payload []byte) ([]byte, error) + Verify(payload, signature []byte) error +} + +type Controller struct { + conf *Config + signers []Signer +} + +func NewController(conf *Config) (*Controller, error) { + if conf.Name == "" { + conf.Name = "whawty-nginx-sso" + } + if conf.Expire <= 0 { + conf.Expire = DefaultExpire + } + + ctrl := &Controller{conf: conf} + for _, sc := range conf.Signers { + var s Signer + if sc.Ed25519 != nil { + var err error + s, err = NewEd25519Signer(conf.Name+"_"+sc.Name, sc.Ed25519) + if err != nil { + return nil, fmt.Errorf("cookies: failed to initialize Ed25519 signer '%s': %v", sc.Name, err) + } + } + if s == nil { + return nil, fmt.Errorf("cookies: failed to initialize signer '%s': no valid type-specific config found", sc.Name) + } + ctrl.signers = append(ctrl.signers, s) + } + if len(ctrl.signers) < 1 { + return nil, fmt.Errorf("cookies: at least one signer must be configured") + } + return ctrl, nil +} + +func (c *Controller) Mint(p Payload) (name, value string, err error) { + p.Expires = time.Now().Add(c.conf.Expire).Unix() + v := &Value{payload: p.Encode()} + if v.signature, err = c.signers[0].Sign(v.payload); err != nil { + return + } + + name = c.conf.Name + value = v.String() + return +} + +func (c *Controller) Verify(value string) (p Payload, err error) { + var v Value + if err = v.FromString(value); err != nil { + return + } + + for _, signer := range c.signers { + if err = signer.Verify(v.payload, v.signature); err == nil { + break + } + } + if err != nil { + err = fmt.Errorf("cookie signature is not valid") + return + } + + if err = p.Decode(v.payload); err != nil { + err = fmt.Errorf("unable to decode cookie: %v", err) + return + } + if time.Unix(p.Expires, 0).Before(time.Now()) { + err = fmt.Errorf("cookie is expired") + return + } + return +} diff --git a/cookie/cookie.go b/cookie/cookie.go new file mode 100644 index 0000000..baa7f01 --- /dev/null +++ b/cookie/cookie.go @@ -0,0 +1,77 @@ +// +// Copyright (c) 2023 whawty contributors (see AUTHORS file) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// * Neither the name of whawty.nginx-sso nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// + +package cookie + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +type Payload struct { + Username string + Expires int64 +} + +func (p Payload) Encode() []byte { + payload, _ := json.Marshal(p) + return payload +} + +func (p Payload) Decode(payload []byte) error { + return json.Unmarshal(payload, &p) +} + +type Value struct { + payload []byte + signature []byte +} + +func (v Value) String() string { + return base64.RawURLEncoding.EncodeToString(v.payload) + "." + base64.RawURLEncoding.EncodeToString(v.signature) +} + +func (v Value) FromString(encoded string) (err error) { + parts := strings.SplitN(encoded, ".", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid cookie value") + } + v.payload, err = base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return fmt.Errorf("invalid cookie value: %v", err) + } + v.signature, err = base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return fmt.Errorf("invalid cookie value: %v", err) + } + return +} diff --git a/cookie/signer_ed25519.go b/cookie/signer_ed25519.go new file mode 100644 index 0000000..b74a490 --- /dev/null +++ b/cookie/signer_ed25519.go @@ -0,0 +1,102 @@ +// +// Copyright (c) 2023 whawty contributors (see AUTHORS file) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// * Neither the name of whawty.nginx-sso nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// + +package cookie + +import ( + "crypto/ed25519" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "os" +) + +// TODO: split up signer and verifier!!! + +type Ed25519Config struct { + Key *string `yaml:"key"` + KeyFile *string `yaml:"key_file"` +} + +type Ed25519Signer struct { + context string + priv ed25519.PrivateKey + pub ed25519.PublicKey +} + +func NewEd25519Signer(context string, conf *Ed25519Config) (*Ed25519Signer, error) { + if conf.Key != nil && conf.KeyFile != nil { + return nil, fmt.Errorf("'key' and 'key_file' are mutually exclusive") + } + + var keyPem []byte + if conf.Key != nil { + keyPem = []byte(*conf.Key) + } + if conf.KeyFile != nil { + kf, err := os.Open(*conf.KeyFile) + if err != nil { + return nil, err + } + defer kf.Close() + + if keyPem, err = io.ReadAll(kf); err != nil { + return nil, err + } + } + if keyPem == nil { + return nil, fmt.Errorf("please set 'key' or 'key_file'") + } + + pemBlock, _ := pem.Decode(keyPem) + if pemBlock == nil { + return nil, fmt.Errorf("no valid PEM encoded block found") + } + keyParsed, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes) + if err != nil { + return nil, err + } + priv, ok := keyParsed.(ed25519.PrivateKey) + if !ok { + return nil, fmt.Errorf("key is not a valid Ed25519 key") + } + pub := priv.Public().(ed25519.PublicKey) + + return &Ed25519Signer{context: context, priv: priv, pub: pub}, nil +} + +func (s Ed25519Signer) Sign(payload []byte) ([]byte, error) { + return s.priv.Sign(nil, payload, &ed25519.Options{Context: s.context}) +} + +func (s Ed25519Signer) Verify(payload, signature []byte) error { + return ed25519.VerifyWithOptions(s.pub, payload, signature, &ed25519.Options{Context: s.context}) +}