Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Redis support for keyshare server and myirmaserver (with failover support) #354

Merged
merged 28 commits into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2659942
Feat: Redis support for keyshare server and myirmaserver
ivard Oct 16, 2023
46307df
Refactor: improve code structure of Redis logic
ivard Oct 18, 2023
173bd02
Refactor: improve subscribe status updates code structure
ivard Oct 18, 2023
b57f836
Improvement: log if deleting expired sessions fails
ivard Oct 18, 2023
9b6c9ac
Fix: sessionMiddleware writing wrong status code on error
ivard Oct 18, 2023
c1c31d3
Improvement: use context in sessionStore
ivard Oct 18, 2023
6b61dad
Chore: prevent superfluous logging
ivard Oct 18, 2023
da4d890
Feat: add support for Redis ACLs
ivard Oct 18, 2023
c05bfe7
Improvement: use username as ACL key prefix
ivard Oct 19, 2023
e4fd8a7
Chore: hide Redis Cluster in help texts
ivard Oct 19, 2023
b9f0fe8
Fix: redis-username flag is not read
ivard Oct 19, 2023
e233673
Improvement: cancel context when session handler returns
ivard Oct 19, 2023
6104383
Chore: improve logging when adding session to Redis
ivard Oct 19, 2023
968f6c3
Chore: updated CHANGELOG.md
ivard Oct 19, 2023
1bc83e0
Fix: clientTokenLookupPrefix not added everywhere
ivard Oct 19, 2023
31bdffd
Fix: ttl not always set correctly on Redis keys
ivard Oct 19, 2023
8a6ae19
Chore: log proofP JWT only in trace mode
ivard Oct 19, 2023
86334a1
Fix: session result handler not called on timeout
ivard Oct 19, 2023
2d7f091
Chore: delete commented import
ivard Oct 24, 2023
2f023a4
Merge branch 'master' into redis-failover
bobhageman Oct 25, 2023
b3a4253
Merge branch 'master' into redis-failover
ivard Oct 27, 2023
7b7fb4d
Feat: use username and passport for Redis Sentinel auth
ivard Nov 20, 2023
f10f9ca
Feat: health endpoint for health checks
ivard Nov 22, 2023
23ed159
Feat: read keyshare storage fallback keys from directory
ivard Nov 24, 2023
439d83a
Fix: STARTTLS not enabled while testing SMTP connection
ivard Nov 27, 2023
63ffadd
Chore: log when keyshare components start listening on port
ivard Nov 27, 2023
4b17038
Fix: connect timeouts to email server not detected
ivard Nov 27, 2023
f1ac276
Merge branch 'master' into redis-failover
bobhageman Dec 6, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased
### Added
- Support for Redis in Sentinel mode
- Redis support for `irma keyshare server` and `irma keyshare myirmaserver`

### Changed
- Using optimistic locking in the `irma server` instead of pessimistic locking

### Internal
- Fixed failing tests due to expired test.test2 idemix key
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ You can then start `irma` with the store-type flag set to Redis and the [default
irma server -vv --store-type redis --redis-addr "localhost:6379" --redis-allow-empty-password --redis-no-tls
```

If you use Redis in Sentinel mode for high availability, you need to consider whether you accept the risk of losing session state in case of a failover. Redis does not guarantee [strong consistency](https://redis.io/docs/management/scaling/#redis-cluster-consistency-guarantees) in these setups. We mitigated this by waiting for a write to have reached the master node and at least one replica. This means that at least two replicas should be configured for every master node to achieve high availability. Even then, there is a small chance of losing session state when a replica fails at the same time as the master node. For example, this might be problematic if you want to guarantee that a credential is not issued twice or if you need a session QR to have a long lifetime but you do want the session to be finished soon after the QR is scanned. If you require IRMA sessions to be highly consistent, you should use the default in-memory store or Redis in standalone mode. If you accept this risk, then you can enable Sentinel mode support by setting the `--redis-accept-inconsistency-risk` flag.

Besides the `irma server`, Redis can also be configured for the `irma keyshare server` and the `irma keyshare myirmaserver` in the same way as described above. Note that the `irma keyshare server` does not become stateless when using Redis, because it stores the keyshare commitments and authentication challenges in memory. These cannot be stored in Redis, because we require this data to be strongly consistent. Instead, you can use sticky sessions to make sure that the same user is always routed to the same keyshare server instance. The stored commitments and challenges are only relevant for a few seconds, so the risk of losing this data is low. The `irma keyshare myirmaserver` does become stateless when using Redis.

## Performance tests
This project only includes performance tests for the `irma keyshare server`. These tests can be run using the [k6 load testing tool](https://k6.io/docs/) and need a running keyshare server instance to test against. Instructions on how to run a keyshare server locally can be found [above](#running).

Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ toolchain go1.21.1
require (
github.com/alexandrevicenzi/go-sse v1.6.0
github.com/alicebob/miniredis/v2 v2.17.0
github.com/bsm/redislock v0.7.2
github.com/bwesterb/go-atum v1.1.5
github.com/eknkc/basex v1.0.1
github.com/fxamacker/cbor v1.5.1
Expand Down
33 changes: 1 addition & 32 deletions go.sum

Large diffs are not rendered by default.

39 changes: 0 additions & 39 deletions internal/sessiontest/redis_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,45 +198,6 @@ func checkErrorInternal(t *testing.T, err error) {
require.Equal(t, string(server.ErrorInternal.Type), serr.RemoteError.ErrorName)
}

func TestRedisUpdates(t *testing.T) {
mr, cert := startRedis(t, true)
defer mr.Close()

irmaServer := StartIrmaServer(t, redisConfigDecorator(mr, cert, "", IrmaServerConfiguration)())
defer irmaServer.Stop()
qr, token, _, err := irmaServer.irma.StartSession(irma.NewDisclosureRequest(
irma.NewAttributeTypeIdentifier("irma-demo.RU.studentCard.studentID"),
), nil)
require.NoError(t, err)

var o interface{}
transport := irma.NewHTTPTransport(qr.URL, false)
transport.SetHeader(irma.MinVersionHeader, "2.8")
transport.SetHeader(irma.MaxVersionHeader, "2.8")
transport.SetHeader(irma.AuthorizationHeader, "testauthtoken")
clientToken, err := mr.Get("token:" + string(token))
require.NoError(t, err)

initialData, _ := mr.Get("session:" + clientToken)
require.NoError(t, transport.Get("", &o))
updatedData, _ := mr.Get("session:" + clientToken)
require.NoError(t, transport.Get("", &o))
latestData, _ := mr.Get("session:" + clientToken)

// First Get should update the data stored in Redis
require.NotEqual(t, updatedData, initialData)
// Second Get should not update the data stored in Redis
require.Equal(t, updatedData, latestData)

// lock session for token
require.NoError(t, mr.Set("lock:"+clientToken, "bla"))
defer mr.Del("lock:" + clientToken)

// try to update locked session
err = transport.Get("", &o)
checkErrorInternal(t, err)
}

func TestRedisRedundancy(t *testing.T) {
mr, cert := startRedis(t, true)
defer mr.Close()
Expand Down
38 changes: 33 additions & 5 deletions irma/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ package cmd

import (
"crypto/tls"
irma "github.com/privacybydesign/irmago"
"github.com/privacybydesign/irmago/server"
"github.com/privacybydesign/irmago/server/keyshare"
"net/smtp"
"os"
"path/filepath"
"regexp"
"strings"

irma "github.com/privacybydesign/irmago"
"github.com/privacybydesign/irmago/server"
"github.com/privacybydesign/irmago/server/keyshare"

"github.com/go-errors/errors"
"github.com/mitchellh/mapstructure"
"github.com/sirupsen/logrus"
Expand Down Expand Up @@ -40,8 +41,8 @@ func configureEmail() keyshare.EmailConfiguration {
}
}

func configureIRMAServer() *server.Configuration {
return &server.Configuration{
func configureIRMAServer() (*server.Configuration, error) {
conf := &server.Configuration{
SchemesPath: viper.GetString("schemes_path"),
SchemesAssetsPath: viper.GetString("schemes_assets_path"),
SchemesUpdateInterval: viper.GetInt("schemes_update"),
Expand All @@ -68,6 +69,33 @@ func configureIRMAServer() *server.Configuration {
AllowUnsignedCallbacks: viper.GetBool("allow_unsigned_callbacks"),
AugmentClientReturnURL: viper.GetBool("augment_client_return_url"),
}

// Parse session store configuration
switch conf.StoreType {
case "redis":
conf.RedisSettings = &server.RedisSettings{}
conf.RedisSettings.Addr = viper.GetString("redis_addr")
conf.RedisSettings.SentinelAddrs = viper.GetStringSlice("redis_sentinel_addrs")
conf.RedisSettings.SentinelMasterName = viper.GetString("redis_sentinel_master_name")
conf.RedisSettings.AcceptInconsistencyRisk = viper.GetBool("redis_accept_inconsistency_risk")

if conf.RedisSettings.Addr == "" && len(conf.RedisSettings.SentinelAddrs) == 0 || conf.RedisSettings.Addr != "" && len(conf.RedisSettings.SentinelAddrs) > 0 {
return nil, errors.New("When Redis is used as session data store, either --redis-addr or --redis-sentinel-addrs must be specified.")
}

conf.RedisSettings.Username = viper.GetString("redis_username")
if conf.RedisSettings.Password = viper.GetString("redis_pw"); conf.RedisSettings.Password == "" && !viper.GetBool("redis_allow_empty_password") {
return nil, errors.New("When Redis is used as session data store, a non-empty Redis password must be specified with the --redis-pw flag. This restriction can be relaxed by setting the --redis-allow-empty-password flag to true.")
}
conf.RedisSettings.ACLUseKeyPrefixes = viper.GetBool("redis_acl_use_key_prefixes")

conf.RedisSettings.DB = viper.GetInt("redis_db")

conf.RedisSettings.TLSCertificate = viper.GetString("redis_tls_cert")
conf.RedisSettings.TLSCertificateFile = viper.GetString("redis_tls_cert_file")
conf.RedisSettings.DisableTLS = viper.GetBool("redis_no_tls")
}
return conf, nil
}

func configureTLS() *tls.Config {
Expand Down
22 changes: 21 additions & 1 deletion irma/cmd/keyshare-myirma.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ func init() {
flags.Int("db-max-idle-time", 0, "Time in seconds after which idle database connections are closed (default unlimited)")
flags.Int("db-max-open-time", 0, "Maximum lifetime in seconds of open database connections (default unlimited)")

headers["store-type"] = "Session store configuration"
flags.String("store-type", "", "specifies how session state will be saved on the server (default \"memory\")")
flags.String("redis-addr", "", "Redis address, to be specified as host:port")
flags.StringSlice("redis-sentinel-addrs", nil, "Redis Sentinel addresses, to be specified as host:port")
flags.String("redis-sentinel-master-name", "", "Redis Sentinel master name")
flags.Bool("redis-accept-inconsistency-risk", false, "accept the risk of inconsistent session state when using Redis Sentinel")
flags.String("redis-username", "", "Redis server username (when using ACLs)")
flags.String("redis-pw", "", "Redis server password")
flags.Bool("redis-allow-empty-password", false, "explicitly allow an empty string as Redis password")
flags.Bool("redis-acl-use-key-prefixes", false, "if enabled all Redis keys will be prefixed with the username for ACLs (username:key)")
flags.Int("redis-db", 0, "database to be selected after connecting to the server (default 0)")
flags.String("redis-tls-cert", "", "use Redis TLS with specific certificate or certificate authority")
flags.String("redis-tls-cert-file", "", "use Redis TLS path to specific certificate or certificate authority")
flags.Bool("redis-no-tls", false, "disable Redis TLS (by default, Redis TLS is enabled with the system certificate pool)")

headers["keyshare-attributes"] = "IRMA session configuration"
flags.StringSlice("keyshare-attributes", nil, "Attributes allowed for login to myirma")
flags.StringSlice("email-attributes", nil, "Attributes allowed for adding email addresses")
Expand Down Expand Up @@ -98,9 +113,14 @@ func init() {
func configureMyirmaServer(cmd *cobra.Command) (*myirmaserver.Configuration, error) {
readConfig(cmd, "myirmaserver", "myirmaserver", []string{".", "/etc/myirmaserver/"}, nil)

irmaServerConf, err := configureIRMAServer()
if err != nil {
return nil, err
}

// And build the configuration
conf := &myirmaserver.Configuration{
Configuration: configureIRMAServer(),
Configuration: irmaServerConf,
EmailConfiguration: configureEmail(),

CORSAllowedOrigins: viper.GetStringSlice("cors_allowed_origins"),
Expand Down
26 changes: 23 additions & 3 deletions irma/cmd/keyshare-server.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,29 @@ func init() {
flags.Int("db-max-idle-time", 0, "Time in seconds after which idle database connections are closed (default unlimited)")
flags.Int("db-max-open-time", 0, "Maximum lifetime in seconds of open database connections (default unlimited)")

headers["store-type"] = "Session store configuration"
flags.String("store-type", "", "specifies how session state will be saved on the server (default \"memory\")")
flags.String("redis-addr", "", "Redis address, to be specified as host:port")
flags.StringSlice("redis-sentinel-addrs", nil, "Redis Sentinel addresses, to be specified as host:port")
flags.String("redis-sentinel-master-name", "", "Redis Sentinel master name")
flags.Bool("redis-accept-inconsistency-risk", false, "accept the risk of inconsistent session state when using Redis Sentinel")
flags.String("redis-username", "", "Redis server username (when using ACLs)")
flags.String("redis-pw", "", "Redis server password")
flags.Bool("redis-allow-empty-password", false, "explicitly allow an empty string as Redis password")
flags.Bool("redis-acl-use-key-prefixes", false, "if enabled all Redis keys will be prefixed with the username for ACLs (username:key)")
flags.Int("redis-db", 0, "database to be selected after connecting to the server (default 0)")
flags.String("redis-tls-cert", "", "use Redis TLS with specific certificate or certificate authority")
flags.String("redis-tls-cert-file", "", "use Redis TLS path to specific certificate or certificate authority")
flags.Bool("redis-no-tls", false, "disable Redis TLS (by default, Redis TLS is enabled with the system certificate pool)")

headers["jwt-privkey"] = "Cryptographic keys"
flags.String("jwt-privkey", "", "Private jwt key of keyshare server")
flags.String("jwt-privkey-file", "", "Path to file containing private jwt key of keyshare server")
flags.Int("jwt-privkey-id", 0, "Key identifier of keyshare server public key matching used private key")
flags.String("jwt-issuer", keysharecore.JWTIssuerDefault, "JWT issuer used in \"iss\" field")
flags.Int("jwt-pin-expiry", keysharecore.JWTPinExpiryDefault, "Expiry of PIN JWT in seconds")
flags.String("storage-primary-keyfile", "", "Primary key used for encrypting and decrypting secure containers")
flags.StringSlice("storage-fallback-keyfile", nil, "Fallback key(s) used to decrypt older secure containers")
flags.String("storage-primary-key-file", "", "Primary key used for encrypting and decrypting secure containers")
flags.StringSlice("storage-fallback-key-file", nil, "Fallback key(s) used to decrypt older secure containers")

headers["keyshare-attribute"] = "Keyshare server attribute issued during registration"
flags.String("keyshare-attribute", "", "Attribute identifier that contains username")
Expand Down Expand Up @@ -98,9 +113,14 @@ func init() {
func configureKeyshareServer(cmd *cobra.Command) (*keyshareserver.Configuration, error) {
readConfig(cmd, "keyshareserver", "keyshareserver", []string{".", "/etc/keyshareserver"}, nil)

irmaServerConf, err := configureIRMAServer()
if err != nil {
return nil, err
}

// And build the configuration
conf := &keyshareserver.Configuration{
Configuration: configureIRMAServer(),
Configuration: irmaServerConf,
EmailConfiguration: configureEmail(),

DBType: keyshareserver.DBType(viper.GetString("db_type")),
Expand Down
35 changes: 13 additions & 22 deletions irma/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,13 @@ func setFlags(cmd *cobra.Command, production bool) error {
headers["store-type"] = "Session store configuration"
flags.String("store-type", "", "specifies how session state will be saved on the server (default \"memory\")")
flags.String("redis-addr", "", "Redis address, to be specified as host:port")
flags.StringSlice("redis-sentinel-addrs", nil, "Redis Sentinel addresses, to be specified as host:port")
flags.String("redis-sentinel-master-name", "", "Redis Sentinel master name")
flags.Bool("redis-accept-inconsistency-risk", false, "accept the risk of inconsistent session state when using Redis Sentinel")
flags.String("redis-username", "", "Redis server username (when using ACLs)")
flags.String("redis-pw", "", "Redis server password")
flags.Bool("redis-allow-empty-password", false, "explicitly allow an empty string as Redis password")
flags.Bool("redis-acl-use-key-prefixes", false, "if enabled all Redis keys will be prefixed with the username for ACLs (username:key)")
flags.Int("redis-db", 0, "database to be selected after connecting to the server (default 0)")
flags.String("redis-tls-cert", "", "use Redis TLS with specific certificate or certificate authority")
flags.String("redis-tls-cert-file", "", "use Redis TLS path to specific certificate or certificate authority")
Expand Down Expand Up @@ -176,9 +181,14 @@ func configureServer(cmd *cobra.Command) (*requestorserver.Configuration, error)
},
)

irmaServerConf, err := configureIRMAServer()
if err != nil {
return nil, err
}

// Read configuration from flags and/or environmental variables
conf := &requestorserver.Configuration{
Configuration: configureIRMAServer(),
Configuration: irmaServerConf,
Permissions: requestorserver.Permissions{
Disclosing: handlePermission("disclose_perms"),
Signing: handlePermission("sign_perms"),
Expand Down Expand Up @@ -217,11 +227,10 @@ func configureServer(cmd *cobra.Command) (*requestorserver.Configuration, error)
}

// Handle requestors
var err error
if err = handleMapOrString("requestors", &conf.Requestors); err != nil {
if err := handleMapOrString("requestors", &conf.Requestors); err != nil {
return nil, err
}
if err = handleMapOrString("static_sessions", &conf.StaticSessions); err != nil {
if err := handleMapOrString("static_sessions", &conf.StaticSessions); err != nil {
return nil, err
}
var m map[string]*irma.RevocationSetting
Expand All @@ -232,24 +241,6 @@ func configureServer(cmd *cobra.Command) (*requestorserver.Configuration, error)
conf.RevocationSettings[irma.NewCredentialTypeIdentifier(i)] = s
}

// Parse Redis store configuration
if conf.StoreType == "redis" {
conf.RedisSettings = &server.RedisSettings{}
if conf.RedisSettings.Addr = viper.GetString("redis_addr"); conf.RedisSettings.Addr == "" {
return nil, errors.New("When Redis is used as session data store, a Redis URL must be specified with the --redis-addr flag.")
}

if conf.RedisSettings.Password = viper.GetString("redis_pw"); conf.RedisSettings.Password == "" && !viper.GetBool("redis_allow_empty_password") {
return nil, errors.New("When Redis is used as session data store, a non-empty Redis password must be specified with the --redis-pw flag. This restriction can be relaxed by setting the --redis-allow-empty-password flag to true.")
}

conf.RedisSettings.DB = viper.GetInt("redis_db")

conf.RedisSettings.TLSCertificate = viper.GetString("redis_tls_cert")
conf.RedisSettings.TLSCertificateFile = viper.GetString("redis_tls_cert_file")
conf.RedisSettings.DisableTLS = viper.GetBool("redis_no_tls")
}

logger.Debug("Done configuring")

return conf, nil
Expand Down
52 changes: 52 additions & 0 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -606,3 +606,55 @@ func FilterStopError(err error) error {
}
return err
}

type HTTPResponseRecorder struct {
wrapped http.ResponseWriter
Flushed bool

statusCode int
header http.Header
body []byte
}

func NewHTTPResponseRecorder(w http.ResponseWriter) *HTTPResponseRecorder {
return &HTTPResponseRecorder{
wrapped: w,
header: w.Header().Clone(),
}
}

// Header implements http.ResponseWriter
func (r *HTTPResponseRecorder) Header() http.Header {
return r.header
}

// Write implements http.ResponseWriter
func (r *HTTPResponseRecorder) Write(b []byte) (int, error) {
r.body = append(r.body, b...)
return len(b), nil
}

// WriteHeader implements http.ResponseWriter
func (r *HTTPResponseRecorder) WriteHeader(statusCode int) {
r.statusCode = statusCode
}

// Flush implements http.Flusher.
func (r *HTTPResponseRecorder) Flush() {
if !r.Flushed {
for k, v := range r.Header() {
r.wrapped.Header()[k] = v
}
if r.statusCode > 0 {
r.wrapped.WriteHeader(r.statusCode)
}
r.Flushed = true
}
if len(r.body) > 0 {
r.wrapped.Write(r.body)
if flusher, ok := r.wrapped.(http.Flusher); ok {
flusher.Flush()
}
r.body = nil
}
}
Loading