diff --git a/politeiad/backendv2/tstorebe/store/mysql/mysql.go b/politeiad/backendv2/tstorebe/store/mysql/mysql.go index 769f8f35bb..629c597ba3 100644 --- a/politeiad/backendv2/tstorebe/store/mysql/mysql.go +++ b/politeiad/backendv2/tstorebe/store/mysql/mysql.go @@ -14,6 +14,7 @@ import ( "github.com/decred/politeia/politeiad/backendv2/tstorebe/store" "github.com/decred/politeia/util" + // MySQL driver. _ "github.com/go-sql-driver/mysql" ) @@ -278,7 +279,9 @@ func (s *mysql) Close() { s.db.Close() } -func New(appDir, host, user, password, dbname string) (*mysql, error) { +// New connects to a mysql instance using the given connection params, +// and returns pointer to the created mysql struct. +func New(host, user, password, dbname string) (*mysql, error) { // The password is required to derive the encryption key if password == "" { return nil, fmt.Errorf("password not provided") diff --git a/politeiad/backendv2/tstorebe/tstore/tstore.go b/politeiad/backendv2/tstorebe/tstore/tstore.go index 831d3169f5..e72b2f86c3 100644 --- a/politeiad/backendv2/tstorebe/tstore/tstore.go +++ b/politeiad/backendv2/tstorebe/tstore/tstore.go @@ -27,7 +27,7 @@ const ( // store to a leveldb instance. DBTypeLevelDB = "leveldb" - // DBTypeLevelDB is a config option that sets the backing key-value + // DBTypeMySQL is a config option that sets the backing key-value // store to a MySQL instance. DBTypeMySQL = "mysql" @@ -227,7 +227,7 @@ func New(appDir, dataDir string, anp *chaincfg.Params, tlogHost, tlogPass, dbTyp case DBTypeMySQL: // Example db name: testnet3_unvetted_kv dbName := fmt.Sprintf("%v_kv", anp.Name) - kvstore, err = mysql.New(appDir, dbHost, dbUser, dbPass, dbName) + kvstore, err = mysql.New(dbHost, dbUser, dbPass, dbName) if err != nil { return nil, err } diff --git a/politeiawww/cmd/politeiawww_dbutil/politeiawww_dbutil.go b/politeiawww/cmd/politeiawww_dbutil/politeiawww_dbutil.go index 0005a4b102..ca81d8c5a4 100644 --- a/politeiawww/cmd/politeiawww_dbutil/politeiawww_dbutil.go +++ b/politeiawww/cmd/politeiawww_dbutil/politeiawww_dbutil.go @@ -32,6 +32,7 @@ import ( "github.com/decred/politeia/politeiawww/user" "github.com/decred/politeia/politeiawww/user/cockroachdb" "github.com/decred/politeia/politeiawww/user/localdb" + "github.com/decred/politeia/politeiawww/user/mysqldb" "github.com/decred/politeia/util" "github.com/google/uuid" _ "github.com/jinzhu/gorm/dialects/postgres" @@ -60,6 +61,7 @@ var ( // Database options level = flag.Bool("leveldb", false, "") cockroach = flag.Bool("cockroachdb", false, "") + mysql = flag.Bool("mysqldb", false, "") // Application options testnet = flag.Bool("testnet", false, "") @@ -593,6 +595,21 @@ func validateCockroachParams() error { return nil } +func validateMysqlParams() error { + // Validate host. + _, err := url.Parse(*host) + if err != nil { + return fmt.Errorf("parse host '%v': %v", *host, err) + } + + // Ensure encryption key file exists. + if !util.FileExists(*encryptionKey) { + return fmt.Errorf("file not found %v", *encryptionKey) + } + + return nil +} + func cmdVerifyIdentities() error { args := flag.Args() if len(args) != 1 { @@ -736,36 +753,41 @@ func _main() error { } else { network = chaincfg.MainNetParams().Name } - // Validate database selection - if *level && *cockroach { - return fmt.Errorf("database choice cannot be both " + - "-leveldb and -cockroachdb") + + // Validate database selection. + switch { + case *mysql && *cockroach, *level && *mysql, *level && *cockroach, + *level && *cockroach && *mysql: + fmt.Println(mysql, cockroach) + return fmt.Errorf("multiple database flags; must use one of the " + + "following: -leveldb, -mysqldb or -cockroachdb") } switch { case *addCredits || *setAdmin || *stubUsers || *resetTotp: - // These commands must be run with -cockroachdb or -leveldb - if !*level && !*cockroach { + // These commands must be run with -cockroachdb, -mysqldb or -leveldb. + if !*level && !*cockroach && !*mysql { return fmt.Errorf("missing database flag; must use " + - "either -leveldb or -cockroachdb") + "-leveldb, -cockroachdb or -mysqldb") } case *dump: - // These commands must be run with -leveldb + // These commands must be run with -leveldb. if !*level { return fmt.Errorf("missing database flag; must use " + "-leveldb with this command") } case *verifyIdentities, *setEmail: - // These commands must be run with -cockroachdb + // These commands must be run with either -cockroachdb or + // -mysqldb. if !*cockroach || *level { return fmt.Errorf("invalid database flag; must use " + - "-cockroachdb with this command") + "either -mysqldb or -cockroachdb with this command") } case *migrate || *createKey: - // These commands must be run without a database flag - if *level || *cockroach { + // These commands must be run without a database flag. + if *level || *cockroach || *mysql { return fmt.Errorf("unexpected database flag found; " + - "remove database flag -leveldb and -cockroachdb") + "remove database flag -leveldb, -mysqldb and -cockroachdb") } } @@ -801,6 +823,19 @@ func _main() error { } userDB = db defer userDB.Close() + + case *mysql: + err := validateMysqlParams() + if err != nil { + return err + } + db, err := mysqldb.New(*host, network, *encryptionKey) + if err != nil { + return fmt.Errorf("new mysqldb: %v", err) + } + userDB = db + defer userDB.Close() + } // Run command diff --git a/politeiawww/config.go b/politeiawww/config.go index 0097cb30c4..a5913a6360 100644 --- a/politeiawww/config.go +++ b/politeiawww/config.go @@ -70,6 +70,7 @@ const ( // User database options userDBLevel = "leveldb" userDBCockroach = "cockroachdb" + userDBMysql = "mysqldb" defaultUserDB = userDBLevel ) @@ -667,7 +668,7 @@ func loadConfig() (*config.Config, []string, error) { } case userDBCockroach: - // Cockroachdb required these settings + // Cockroachdb requires these settings. switch { case cfg.DBHost == "": return nil, nil, fmt.Errorf("dbhost param is required") @@ -731,9 +732,44 @@ func loadConfig() (*config.Config, []string, error) { } } + case userDBMysql: + // Mysql requires database host setting. + if cfg.DBHost == "" { + return nil, nil, fmt.Errorf("dbhost param is required") + } + + // Validate user database host. + _, err = url.Parse(cfg.DBHost) + if err != nil { + return nil, nil, fmt.Errorf("parse dbhost: %v", err) + } + + // Validate user database encryption keys. + cfg.EncryptionKey = util.CleanAndExpandPath(cfg.EncryptionKey) + cfg.OldEncryptionKey = util.CleanAndExpandPath(cfg.OldEncryptionKey) + + if cfg.EncryptionKey != "" && !util.FileExists(cfg.EncryptionKey) { + return nil, nil, fmt.Errorf("file not found %v", cfg.EncryptionKey) + } + + if cfg.OldEncryptionKey != "" { + switch { + case cfg.EncryptionKey == "": + return nil, nil, fmt.Errorf("old encryption key param " + + "cannot be used without encryption key param") + + case cfg.EncryptionKey == cfg.OldEncryptionKey: + return nil, nil, fmt.Errorf("old encryption key param " + + "and encryption key param must be different") + + case !util.FileExists(cfg.OldEncryptionKey): + return nil, nil, fmt.Errorf("file not found %v", cfg.OldEncryptionKey) + } + } + default: return nil, nil, fmt.Errorf("invalid userdb '%v'; must "+ - "be either leveldb or cockroachdb", cfg.UserDB) + "be leveldb, cockroachdb or mysqldb", cfg.UserDB) } // Verify paywall settings diff --git a/politeiawww/user/mysqldb/log.go b/politeiawww/user/mysqldb/log.go new file mode 100644 index 0000000000..16e9a0ec0a --- /dev/null +++ b/politeiawww/user/mysqldb/log.go @@ -0,0 +1,21 @@ +package mysqldb + +import "github.com/decred/slog" + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the caller +// requests it. +var log = slog.Disabled + +// DisableLog disables all library log output. Logging output is disabled +// by default until either UseLogger or SetLogWriter are called. +func DisableLog() { + log = slog.Disabled +} + +// UseLogger uses a specified Logger to output package logging info. +// This should be used in preference to SetLogWriter if the caller is also +// using slog. +func UseLogger(logger slog.Logger) { + log = logger +} diff --git a/politeiawww/user/mysqldb/mysqldb.go b/politeiawww/user/mysqldb/mysqldb.go new file mode 100644 index 0000000000..14f19ddca7 --- /dev/null +++ b/politeiawww/user/mysqldb/mysqldb.go @@ -0,0 +1,952 @@ +package mysqldb + +import ( + "bytes" + "context" + "database/sql" + "encoding/binary" + "encoding/hex" + "fmt" + "sync" + "time" + + "github.com/decred/politeia/politeiawww/user" + "github.com/decred/politeia/util" + "github.com/google/uuid" + "github.com/marcopeereboom/sbox" + + // MySQL driver. + _ "github.com/go-sql-driver/mysql" +) + +const ( + // Database options + connTimeout = 1 * time.Minute + connMaxLifetime = 1 * time.Minute + maxOpenConns = 0 // 0 is unlimited + maxIdleConns = 100 + + // Database user (read/write access) + userPoliteiawww = "politeiawww" + + databaseID = "users" + + // Database table names. + tableNameKeyValue = "key_value" + tableNameUsers = "users" + tableNameIdentities = "identities" + tableNameSessions = "sessions" + + // Key-value store keys. + keyVersion = "version" + keyPaywallAddressIndex = "paywalladdressindex" +) + +// tableKeyValue defines the key-value table. +const tableKeyValue = ` + k VARCHAR(255) NOT NULL PRIMARY KEY, + v LONGBLOB NOT NULL +` + +// tableUsers defines the users table. +const tableUsers = ` + ID VARCHAR(36) NOT NULL PRIMARY KEY, + username VARCHAR(64) NOT NULL, + uBlob BLOB NOT NULL, + createdAt INT(11) NOT NULL, + updatedAt INT(11), + UNIQUE (username) +` + +// tableIdentities defines the identities table. +const tableIdentities = ` + publicKey CHAR(68) NOT NULL PRIMARY KEY, + userID VARCHAR(36) NOT NULL, + activated INT(11) NOT NULL, + deactivated INT(11) NOT NULL, + FOREIGN KEY (userID) REFERENCES users(ID) +` + +// tableSessions defines the sessions table. +const tableSessions = ` + k CHAR(64) NOT NULL PRIMARY KEY, + userID VARCHAR(36) NOT NULL, + createdAt INT(11) NOT NULL, + sBlob BLOB NOT NULL +` + +var ( + _ user.Database = (*mysqldb)(nil) +) + +// mysqldb implements the user.Database interface. +type mysqldb struct { + sync.RWMutex + + shutdown bool // Backend is shutdown + userDB *sql.DB // Database context + encryptionKey *[32]byte // Data at rest encryption key + pluginSettings map[string][]user.PluginSetting // [pluginID][]PluginSettings +} + +func ctxWithTimeout() (context.Context, func()) { + return context.WithTimeout(context.Background(), connTimeout) +} + +func (m *mysqldb) isShutdown() bool { + m.RLock() + defer m.RUnlock() + + return m.shutdown +} + +// encrypt encrypts the provided data with the mysqldb encryption key. The +// encrypted blob is prefixed with an sbox header which encodes the provided +// version. The read lock is taken despite the encryption key being a static +// value because the encryption key is zeroed out on shutdown, which causes +// race conditions to be reported when the golang race detector is used. +// +// This function must be called without the lock held. +func (m *mysqldb) encrypt(version uint32, b []byte) ([]byte, error) { + m.RLock() + defer m.RUnlock() + + return sbox.Encrypt(version, m.encryptionKey, b) +} + +// decrypt decrypts the provided packed blob using the mysqldb encryption +// key. The read lock is taken despite the encryption key being a static value +// because the encryption key is zeroed out on shutdown, which causes race +// conditions to be reported when the golang race detector is used. +// +// This function must be called without the lock held. +func (m *mysqldb) decrypt(b []byte) ([]byte, uint32, error) { + m.RLock() + defer m.RUnlock() + + return sbox.Decrypt(m.encryptionKey, b) +} + +// setPaywallAddressIndex updates the paywall address index record in the +// key-value store. +// +// This function should be called using a transaction necessary. +func setPaywallAddressIndex(ctx context.Context, tx *sql.Tx, index uint64) error { + b := make([]byte, 8) + binary.LittleEndian.PutUint64(b, index) + _, err := tx.ExecContext(ctx, + "UPDATE key_value SET v = ? WHERE k = ?", b, keyPaywallAddressIndex) + if err != nil { + return fmt.Errorf("update paywallet index error: %v", err) + } + return nil +} + +// userNew creates a new user the database. The userID and paywall address +// index are set before the user record is inserted into the database. +// +// This function must be called using a transaction. +func (m *mysqldb) userNew(ctx context.Context, tx *sql.Tx, u user.User) (*uuid.UUID, error) { + // Set user paywall address index. + var index uint64 + var dbIndex []byte + err := tx.QueryRowContext(ctx, "SELECT v FROM key_value WHERE k=?", + keyPaywallAddressIndex).Scan(&dbIndex) + switch err { + // No errors, use database index. + case nil: + index = binary.LittleEndian.Uint64(dbIndex) + 1 + // No rows found error; Index wasn't initiated in table yet, default to zero. + case sql.ErrNoRows: + index = 0 + // All other errors. + default: + return nil, fmt.Errorf("find paywall index: %v", err) + } + + u.PaywallAddressIndex = index + + // Set user ID. + u.ID = uuid.New() + + // Create user record. + ub, err := user.EncodeUser(u) + if err != nil { + return nil, err + } + + eb, err := m.encrypt(user.VersionUser, ub) + if err != nil { + return nil, err + } + + // Insert new user into database. + ur := struct { + ID string + Username string + Blob []byte + CreatedAt int64 + }{ + ID: u.ID.String(), + Username: u.Username, + Blob: eb, + CreatedAt: time.Now().Unix(), + } + _, err = tx.ExecContext(ctx, + "INSERT into users (ID, username, uBlob, createdAt) VALUES (?, ?, ?, ?)", + ur.ID, ur.Username, ur.Blob, ur.CreatedAt) + if err != nil { + return nil, fmt.Errorf("create user: %v", err) + } + + // Update paywall address index. + err = setPaywallAddressIndex(ctx, tx, index) + if err != nil { + return nil, fmt.Errorf("set paywall index: %v", err) + } + + return &u.ID, nil +} + +// rotateKeys rotates the existing database encryption key with the given new +// key. +// +// This function must be called using a transaction. +func rotateKeys(ctx context.Context, tx *sql.Tx, oldKey *[32]byte, newKey *[32]byte) error { + // Rotate keys for users table. + type User struct { + ID string // UUID + Blob []byte // Encrypted blob of user data. + } + var users []User + + rows, err := tx.QueryContext(ctx, "SELECT ID, uBlob FROM users") + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Blob); err != nil { + return err + } + users = append(users, u) + } + // Rows.Err will report the last error encountered by Rows.Scan. + if err := rows.Err(); err != nil { + return err + } + + for _, v := range users { + b, _, err := sbox.Decrypt(oldKey, v.Blob) + if err != nil { + return fmt.Errorf("decrypt user '%v': %v", + v.ID, err) + } + + eb, err := sbox.Encrypt(user.VersionUser, newKey, b) + if err != nil { + return fmt.Errorf("encrypt user '%v': %v", + v.ID, err) + } + + v.Blob = eb + // Store new user blob. + _, err = tx.ExecContext(ctx, + "UPDATE users SET uBlob = ? WHERE ID = ?", v.Blob, v.ID) + if err != nil { + return fmt.Errorf("save user '%v': %v", v.ID, err) + } + } + + // Rotate keys for sessions table. + type Session struct { + Key string + Blob []byte // Encrypted blob of session data. + } + var sessions []Session + rows, err = tx.QueryContext(ctx, "SELECT k, sBlob FROM sessions") + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var s Session + if err := rows.Scan(&s.Key, &s.Blob); err != nil { + return err + } + sessions = append(sessions, s) + } + // Rows.Err will report the last error encountered by Rows.Scan. + if err := rows.Err(); err != nil { + return err + } + + for _, v := range sessions { + b, _, err := sbox.Decrypt(oldKey, v.Blob) + if err != nil { + return fmt.Errorf("decrypt session '%v': %v", + v.Key, err) + } + + eb, err := sbox.Encrypt(user.VersionSession, newKey, b) + if err != nil { + return fmt.Errorf("encrypt session '%v': %v", + v.Key, err) + } + + v.Blob = eb + // Store new user blob. + _, err = tx.ExecContext(ctx, + "UPDATE sessions SET sBlob = ? WHERE k = ?", v.Blob, v.Key) + if err != nil { + return fmt.Errorf("save session '%v': %v", v.Key, err) + } + } + + return nil +} + +// UserNew adds a new user. +func (m *mysqldb) UserNew(u user.User) error { + log.Tracef("UserNew: %v", u.Username) + + if m.isShutdown() { + return user.ErrShutdown + } + + ctx, cancel := ctxWithTimeout() + defer cancel() + + // Start transaction. + opts := &sql.TxOptions{ + Isolation: sql.LevelDefault, + } + tx, err := m.userDB.BeginTx(ctx, opts) + defer tx.Rollback() + if err != nil { + return fmt.Errorf("begin tx: %v", err) + } + + _, err = m.userNew(ctx, tx, u) + if err != nil { + return err + } + + // Commit transaction. + if err := tx.Commit(); err != nil { + return err + } + + return nil +} + +// UserUpdate updates an existing user. +func (m *mysqldb) UserUpdate(u user.User) error { + log.Tracef("UserUpdate: %v", u.Username) + + if m.isShutdown() { + return user.ErrShutdown + } + + b, err := user.EncodeUser(u) + if err != nil { + return err + } + + eb, err := m.encrypt(user.VersionUser, b) + if err != nil { + return err + } + + ctx, cancel := ctxWithTimeout() + defer cancel() + + ur := struct { + ID string + Username string + Blob []byte + UpdatedAt int64 + }{ + ID: u.ID.String(), + Username: u.Username, + Blob: eb, + UpdatedAt: time.Now().Unix(), + } + _, err = m.userDB.ExecContext(ctx, + "UPDATE users SET username = ?, uBlob = ?, updatedAt = ? WHERE ID = ? ", + ur.Username, ur.Blob, ur.UpdatedAt, ur.ID) + if err != nil { + return fmt.Errorf("create user: %v", err) + } + + return nil +} + +// UserGetByUsername returns user record given the username. +func (m *mysqldb) UserGetByUsername(username string) (*user.User, error) { + log.Tracef("UserGetByUsername: %v", username) + + if m.isShutdown() { + return nil, user.ErrShutdown + } + + ctx, cancel := ctxWithTimeout() + defer cancel() + + var uBlob []byte + err := m.userDB.QueryRowContext(ctx, + "SELECT uBlob FROM users WHERE username=?", username).Scan(&uBlob) + switch { + case err == sql.ErrNoRows: + return nil, user.ErrUserNotFound + case err != nil: + return nil, err + } + + b, _, err := m.decrypt(uBlob) + if err != nil { + return nil, err + } + + usr, err := user.DecodeUser(b) + if err != nil { + return nil, err + } + + return usr, nil +} + +// UserGetById returns user record given its id. +func (m *mysqldb) UserGetById(id uuid.UUID) (*user.User, error) { + log.Tracef("UserGetById: %v", id) + + if m.isShutdown() { + return nil, user.ErrShutdown + } + + ctx, cancel := ctxWithTimeout() + defer cancel() + + var uBlob []byte + err := m.userDB.QueryRowContext(ctx, + "SELECT uBlob FROM users WHERE ID=?", id).Scan(&uBlob) + switch { + case err == sql.ErrNoRows: + return nil, user.ErrUserNotFound + case err != nil: + return nil, err + } + + b, _, err := m.decrypt(uBlob) + if err != nil { + return nil, err + } + + usr, err := user.DecodeUser(b) + if err != nil { + return nil, err + } + + return usr, nil +} + +// UserGetByPubKey returns user record given a public key. +func (m *mysqldb) UserGetByPubKey(pubKey string) (*user.User, error) { + log.Tracef("UserGetByPubKey: %v", pubKey) + + if m.isShutdown() { + return nil, user.ErrShutdown + } + + ctx, cancel := ctxWithTimeout() + defer cancel() + + var uBlob []byte + q := `SELECT uBlob + FROM users + INNER JOIN identities + ON users.ID = identities.userID + WHERE identities.publicKey = ?` + err := m.userDB.QueryRowContext(ctx, q, pubKey).Scan(&uBlob) + switch { + case err == sql.ErrNoRows: + return nil, user.ErrUserNotFound + case err != nil: + return nil, err + } + + b, _, err := m.decrypt(uBlob) + if err != nil { + return nil, err + } + usr, err := user.DecodeUser(b) + if err != nil { + return nil, err + } + + return usr, nil +} + +// UsersGetByPubKey returns a map of public key to user record. +func (m *mysqldb) UsersGetByPubKey(pubKeys []string) (map[string]user.User, error) { + log.Tracef("UserGetByPubKey: %v", pubKeys) + + if m.isShutdown() { + return nil, user.ErrShutdown + } + + ctx, cancel := ctxWithTimeout() + defer cancel() + + // Lookup users by pubkey. + q := `SELECT uBlob + FROM users + INNER JOIN identities + ON users.ID = identities.ID + WHERE identities.publicKey IN (?)` + rows, err := m.userDB.QueryContext(ctx, q, pubKeys) + if err != nil { + return nil, err + } + defer rows.Close() + + // Put provided pubkeys into a map + pk := make(map[string]struct{}, len(pubKeys)) + for _, v := range pubKeys { + pk[v] = struct{}{} + } + + // Decrypt user data blobs and compile a users map for + // the provided pubkeys. + users := make(map[string]user.User, len(pubKeys)) // [pubkey]User + for rows.Next() { + var uBlob []byte + err := rows.Scan(&uBlob) + if err != nil { + return nil, err + } + + b, _, err := m.decrypt(uBlob) + if err != nil { + return nil, err + } + + usr, err := user.DecodeUser(b) + if err != nil { + return nil, err + } + + for _, id := range usr.Identities { + _, ok := pk[id.String()] + if ok { + users[id.String()] = *usr + } + } + } + if err = rows.Err(); err != nil { + return nil, err + } + + return users, nil +} + +// AllUsers iterate over all users and executes given callback. +func (m *mysqldb) AllUsers(callback func(u *user.User)) error { + log.Tracef("AllUsers") + + if m.isShutdown() { + return user.ErrShutdown + } + + ctx, cancel := ctxWithTimeout() + defer cancel() + + // Lookup all users. + type User struct { + Blob []byte + } + var users []User + rows, err := m.userDB.QueryContext(ctx, "SELECT uBlob FROM users") + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var u User + err := rows.Scan(&u.Blob) + if err != nil { + return err + } + users = append(users, u) + } + + // Invoke callback on each user. + for _, v := range users { + b, _, err := m.decrypt(v.Blob) + if err != nil { + return err + } + + u, err := user.DecodeUser(b) + if err != nil { + return err + } + + callback(u) + } + + return nil +} + +// SessionSave creates or updates a user session. +func (m *mysqldb) SessionSave(us user.Session) error { + log.Tracef("SessionSave: %v", us.ID) + + if m.isShutdown() { + return user.ErrShutdown + } + + ctx, cancel := ctxWithTimeout() + defer cancel() + + type Session struct { + Key string // SHA256 hash of the session ID + UserID string // User UUID + CreatedAt int64 // Created at UNIX timestamp + Blob []byte // Encrypted user session + } + sb, err := user.EncodeSession(us) + if err != nil { + return nil + } + eb, err := m.encrypt(user.VersionSession, sb) + if err != nil { + return err + } + session := Session{ + Key: hex.EncodeToString(util.Digest([]byte(us.ID))), + UserID: us.UserID.String(), + CreatedAt: us.CreatedAt, + Blob: eb, + } + + // Check if session already exists. + var ( + update bool + k string + ) + err = m.userDB. + QueryRowContext(ctx, "SELECT k FROM sessions WHERE k = ?", session.Key). + Scan(&k) + switch err { + case nil: + // Session already exists; update existing session. + update = true + case sql.ErrNoRows: + // Session doesn't exist; continue. + default: + // All other errors. + return fmt.Errorf("lookup: %v", err) + } + + // Save session record + if update { + _, err := m.userDB.ExecContext(ctx, + `UPDATE sessions + SET userID = ?, + CreatedAt = ?, + sBlob = ?`, + session.UserID, session.CreatedAt, session.Blob) + if err != nil { + return fmt.Errorf("upate: %v", err) + } + } else { + _, err := m.userDB.ExecContext(ctx, + `INSERT INTO sessions + (k, userID, createdAt, sBlob) + VALUES (?, ?, ?, ?)`, + session.Key, session.UserID, session.CreatedAt, session.Blob) + if err != nil { + return fmt.Errorf("create: %v", err) + } + } + + return nil +} + +// SessionGetByID returns a user session given its id. +func (m *mysqldb) SessionGetByID(sid string) (*user.Session, error) { + log.Tracef("SessionGetByID: %v", sid) + + if m.isShutdown() { + return nil, user.ErrShutdown + } + + ctx, cancel := ctxWithTimeout() + defer cancel() + + var blob []byte + err := m.userDB.QueryRowContext(ctx, "SELECT sBlob FROM sessions WHERE k = ?", + hex.EncodeToString(util.Digest([]byte(sid)))). + Scan(&blob) + switch { + case err == sql.ErrNoRows: + return nil, user.ErrUserNotFound + case err != nil: + return nil, err + } + + b, _, err := m.decrypt(blob) + if err != nil { + return nil, err + } + return user.DecodeSession(b) +} + +// SessionDeleteByID deletes a user session given its id. +func (m *mysqldb) SessionDeleteByID(sid string) error { + log.Tracef("SessionDeleteByID: %v", sid) + + if m.isShutdown() { + return user.ErrShutdown + } + + ctx, cancel := ctxWithTimeout() + defer cancel() + + _, err := m.userDB.ExecContext(ctx, "DELETE FROM sessions WHERE k=?", + hex.EncodeToString(util.Digest([]byte(sid)))) + if err != nil { + return err + } + + return nil +} + +// SessionsDeleteByUserID deletes all sessions for a user except for the given +// session IDs. +func (m *mysqldb) SessionsDeleteByUserID(uid uuid.UUID, exemptSessionIDs []string) error { + log.Tracef("SessionsDeleteByUserID: %v %v", uid.String(), exemptSessionIDs) + + ctx, cancel := ctxWithTimeout() + defer cancel() + + // Session primary key is a SHA256 hash of the session ID. + exempt := make([]string, 0, len(exemptSessionIDs)) + for _, v := range exemptSessionIDs { + exempt = append(exempt, hex.EncodeToString(util.Digest([]byte(v)))) + } + + // Using an empty NOT IN() set will result in no records being + // deleted. + if len(exempt) == 0 { + _, err := m.userDB. + ExecContext(ctx, "DELETE FROM sessions WHERE userID = ?", uid.String()) + return err + } + + _, err := m.userDB. + ExecContext(ctx, "DELETE FROM sessions WHERE usedID = ? AND key NOT IN (?)", + uid.String(), exempt) + return err +} + +// RegisterPlugin registers a plugin. +func (m *mysqldb) RegisterPlugin(p user.Plugin) error { + log.Tracef("RegisterPlugin: %v %v", p.ID, p.Version) + + if m.isShutdown() { + return user.ErrShutdown + } + + // Setup plugin tables + var err error + switch p.ID { + case user.CMSPluginID: + // XXX add the following: + // err = m.cmsPluginSetup() + default: + return user.ErrInvalidPlugin + } + if err != nil { + return err + } + + // Save plugin settings. + m.Lock() + defer m.Unlock() + + m.pluginSettings[p.ID] = p.Settings + + return nil +} + +// RotateKeys rotates the existing database encryption key with the given new +// key. +func (m *mysqldb) RotateKeys(newKeyPath string) error { + log.Tracef("RotateKeys: %v", newKeyPath) + + if m.isShutdown() { + return user.ErrShutdown + } + + // Load and validate new encryption key. + newKey, err := util.LoadEncryptionKey(log, newKeyPath) + if err != nil { + return fmt.Errorf("load encryption key '%v': %v", + newKeyPath, err) + } + + if bytes.Equal(newKey[:], m.encryptionKey[:]) { + return fmt.Errorf("keys are the same") + } + + log.Infof("Rotating encryption keys") + + ctx, cancel := ctxWithTimeout() + defer cancel() + + m.Lock() + defer m.Unlock() + + // Rotate keys using a transaction. + opts := &sql.TxOptions{ + Isolation: sql.LevelDefault, + } + tx, err := m.userDB.BeginTx(ctx, opts) + defer tx.Rollback() + if err != nil { + return err + } + err = rotateKeys(ctx, tx, m.encryptionKey, newKey) + if err != nil { + return err + } + + // Commit transaction. + if err := tx.Commit(); err != nil { + return err + } + + // Update context. + m.encryptionKey = newKey + + return nil +} + +// New connects to a mysql instance using the given connection params, +// and returns pointer to the created mysql struct. +func New(host, network, encryptionKey string) (*mysqldb, error) { + // Connect to database. + dbname := databaseID + "_" + network + password := "" + log.Infof("MySQL host: %v:[password]@tcp(%v)/%v", userPoliteiawww, host, + dbname) + + h := fmt.Sprintf("%v:%v@tcp(%v)/%v", userPoliteiawww, password, host, dbname) + db, err := sql.Open("mysql", h) + if err != nil { + return nil, err + } + + // Setup database options. + db.SetConnMaxLifetime(connMaxLifetime) + db.SetMaxOpenConns(maxOpenConns) + db.SetMaxIdleConns(maxIdleConns) + + // Verify database connection. + err = db.Ping() + if err != nil { + return nil, fmt.Errorf("db ping: %v", err) + } + + // Setup key-value table. + q := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %v (%v)`, + tableNameKeyValue, tableKeyValue) + _, err = db.Exec(q) + if err != nil { + return nil, fmt.Errorf("create %v table: %v", tableNameKeyValue, err) + } + + // Setup users table. + q = fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %v (%v)`, + tableNameUsers, tableUsers) + _, err = db.Exec(q) + if err != nil { + return nil, fmt.Errorf("create %v table: %v", tableNameUsers, err) + } + + // Setup identities table. + q = fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %v (%v)`, + tableNameIdentities, tableIdentities) + _, err = db.Exec(q) + if err != nil { + return nil, fmt.Errorf("create %v table: %v", tableNameIdentities, err) + } + + // Setup sessions table. + q = fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %v (%v)`, + tableNameSessions, tableSessions) + _, err = db.Exec(q) + if err != nil { + return nil, fmt.Errorf("create %v table: %v", tableNameSessions, err) + } + + // Load encryption key. + key, err := util.LoadEncryptionKey(log, encryptionKey) + if err != nil { + return nil, err + } + + return &mysqldb{ + userDB: db, + encryptionKey: key, + }, nil +} + +// PluginExec executes a plugin command. +func (m *mysqldb) PluginExec(pc user.PluginCommand) (*user.PluginCommandReply, error) { + log.Tracef("PluginExec: %v %v", pc.ID, pc.Command) + + if m.isShutdown() { + return nil, user.ErrShutdown + } + + var payload string + var err error + switch pc.ID { + case user.CMSPluginID: + // XXX add cms plgunin commands. + // payload, err = c.cmsPluginExec(pc.Command, pc.Payload) + default: + return nil, user.ErrInvalidPlugin + } + if err != nil { + return nil, err + } + + return &user.PluginCommandReply{ + ID: pc.ID, + Command: pc.Command, + Payload: payload, + }, nil +} + +// Close performs cleanup of the backend. +func (m *mysqldb) Close() error { + log.Tracef("Close") + + m.Lock() + defer m.Unlock() + + // Zero out encryption key. + util.Zero(m.encryptionKey[:]) + m.encryptionKey = nil + + m.shutdown = true + return m.userDB.Close() +} diff --git a/politeiawww/www.go b/politeiawww/www.go index 50f8be4767..09c257b015 100644 --- a/politeiawww/www.go +++ b/politeiawww/www.go @@ -40,6 +40,7 @@ import ( "github.com/decred/politeia/politeiawww/user" "github.com/decred/politeia/politeiawww/user/cockroachdb" "github.com/decred/politeia/politeiawww/user/localdb" + "github.com/decred/politeia/politeiawww/user/mysqldb" "github.com/decred/politeia/util" "github.com/decred/politeia/util/version" "github.com/decred/politeia/wsdcrdata" @@ -628,6 +629,33 @@ func _main() error { } } + case userDBMysql: + // If old encryption key is set it means that we need + // to open a db connection using the old key and then + // rotate keys. + var encryptionKey string + if loadedCfg.OldEncryptionKey != "" { + encryptionKey = loadedCfg.OldEncryptionKey + } else { + encryptionKey = loadedCfg.EncryptionKey + } + + // Open db connection. + network := filepath.Base(loadedCfg.DataDir) + db, err := mysqldb.New(loadedCfg.DBHost, network, encryptionKey) + if err != nil { + return fmt.Errorf("new mysql db: %v", err) + } + userDB = db + + // Rotate keys. + if loadedCfg.OldEncryptionKey != "" { + err = db.RotateKeys(loadedCfg.EncryptionKey) + if err != nil { + return fmt.Errorf("rotate userdb keys: %v", err) + } + } + default: return fmt.Errorf("invalid userdb '%v'", loadedCfg.UserDB) } diff --git a/scripts/userdbsetup.sh b/scripts/userdb/cockroachsetup.sh similarity index 100% rename from scripts/userdbsetup.sh rename to scripts/userdb/cockroachsetup.sh diff --git a/scripts/userdb/mysqlsetup.sh b/scripts/userdb/mysqlsetup.sh new file mode 100755 index 0000000000..dbac88be1a --- /dev/null +++ b/scripts/userdb/mysqlsetup.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# This script sets up the MySql databases for the politeiawww user data +# and assigns user privileges. +# This script requires that you have a MySql instance listening on the +# default port localhost:3306. + +set -ex + +# Database names. +readonly DB_MAINNET="users_mainnet" +readonly DB_TESTNET="users_testnet3" + +# Database usernames. +readonly USER_POLITEIAWWW="politeiawww" + + +# Create the mainnet and testnet databases for the politeiawww user data. +mysql -u root \ + -e "CREATE DATABASE IF NOT EXISTS ${DB_MAINNET}" + +mysql -u root \ + -e "CREATE DATABASE IF NOT EXISTS ${DB_TESTNET}" + +# Create politeiawww user and assign privileges. +mysql -u root \ + -e "CREATE USER IF NOT EXISTS ${USER_POLITEIAWWW}" + +mysql -u root \ + -e "GRANT ALL PRIVILEGES \ + ON ${DB_MAINNET}.* TO ${USER_POLITEIAWWW}" + +mysql -u root \ + -e "GRANT ALL PRIVILEGES \ + ON ${DB_TESTNET}.* TO ${USER_POLITEIAWWW}"