diff --git a/cmd/init.go b/cmd/init.go index c34f0e1..58e36ba 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -6,6 +6,7 @@ import ( "strings" batch_balance "github.com/grassrootseconomics/cic-go/batch_balance" + "github.com/grassrootseconomics/cic-go/meta" cic_net "github.com/grassrootseconomics/cic-go/net" "github.com/ethereum/go-ethereum/common" @@ -40,12 +41,19 @@ type config struct { Enabled bool `koan:"enabled"` } Syncers map[string]string `koanf:"syncers"` + Meta struct { + Endpoint string `koanf:"meta"` + } + Jwt struct { + Secret string `koanf:"secret"` + } } type queries struct { core goyesql.Queries dashboard goyesql.Queries public goyesql.Queries + admin goyesql.Queries } func loadConfig(configFilePath string, k *koanf.Koanf) error { @@ -110,6 +118,10 @@ func loadCicNet(tokenIndex common.Address) error { return nil } +func loadCicMeta(metaEndpoint string) { + metaClient = meta.NewCicMeta(metaEndpoint) +} + func loadBatchBalance(balanceResolver common.Address) error { var err error @@ -137,10 +149,16 @@ func loadQueries(sqlFilesPath string) error { return err } + adminQueries, err := goyesql.ParseFile(fmt.Sprintf("%s/admin.sql", sqlFilesPath)) + if err != nil { + return err + } + preparedQueries = &queries{ core: coreQueries, dashboard: dashboardQueries, public: publicQueries, + admin: adminQueries, } return nil diff --git a/cmd/main.go b/cmd/main.go index c61d2d3..18d3746 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -8,6 +8,7 @@ import ( "time" batch_balance "github.com/grassrootseconomics/cic-go/batch_balance" + "github.com/grassrootseconomics/cic-go/meta" cic_net "github.com/grassrootseconomics/cic-go/net" "github.com/grassrootseconomics/cic-go/provider" "github.com/hibiken/asynq" @@ -28,6 +29,7 @@ var ( rpcProvider *provider.Provider cicnetClient *cic_net.CicNet batchBalance *batch_balance.BatchBalance + metaClient *meta.CicMeta rClient asynq.RedisConnOpt ) @@ -59,9 +61,10 @@ func init() { } if err := parseRedis(conf.Db.Redis); err != nil { - log.Fatal().Err(err).Msg("could not parse redis connection string") } + + loadCicMeta(conf.Meta.Endpoint) } func main() { diff --git a/cmd/server.go b/cmd/server.go index 9f81bc5..54ffcd3 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -1,6 +1,7 @@ package main import ( + "cic-dw/internal/admin" "cic-dw/internal/dashboard" "cic-dw/internal/public" @@ -21,6 +22,7 @@ func initHTTPServer() *echo.Echo { dashboard.InitDashboardApi(server, db, preparedQueries.dashboard) public.InitPublicApi(server, db, batchBalance, cicnetClient, preparedQueries.public) + admin.InitAdminApi(server, db, preparedQueries.admin, metaClient, conf.Jwt.Secret) return server } diff --git a/config.toml b/config.toml index 5bb33cb..cce4ed3 100644 --- a/config.toml +++ b/config.toml @@ -25,4 +25,10 @@ enabled = true [syncers] cache = "@every 20s" ussd = "@every 30s" -token = "@every 1m" \ No newline at end of file +token = "@every 1m" + +[meta] +endpoint = "http://cic-meta-server:8000" + +[jwt] +secret = "0xd34db33f" \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index 5c6bf24..e5c1296 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,7 +4,7 @@ The data warehouse additionally exposes a couple of REST API's (GraphQL planned) 1. Dashboard API (`/dashboard`) - Exposes data for [`cic-dashboard`](https://github.com/grassrootseconomics/cic-dashboard). Most data is expected to be chart/table API specific and usually not human readable. 2. Public API (`/public`) - Exposes public (on-chain only/non-sensitive) data. -3. Internal API (planned) +3. Internal Admin API - back office operations Each API is domain separated i.e. separate SQL query files and router control. diff --git a/go.mod b/go.mod index 263a089..266343c 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/go-redis/redis/v8 v8.11.2 // indirect github.com/go-stack/stack v1.8.0 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang-jwt/jwt/v4 v4.4.1 // indirect github.com/golang/protobuf v1.4.3 // indirect github.com/google/uuid v1.2.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect @@ -41,6 +42,7 @@ require ( github.com/jackc/pgtype v1.11.0 // indirect github.com/jackc/puddle v1.2.1 // indirect github.com/labstack/gommon v0.3.1 // indirect + github.com/mapaiva/vcard-go v1.2.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect diff --git a/go.sum b/go.sum index 343a03e..eede51a 100644 --- a/go.sum +++ b/go.sum @@ -169,6 +169,8 @@ github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXP github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.3.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= +github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-module/carbon/v2 v2.1.6 h1:PL7wjvWG+NfUL2hIYOUX68yMi0A9rrCVlJxozldJ1lU= github.com/golang-module/carbon/v2 v2.1.6/go.mod h1:NF5unWf838+pyRY0o+qZdIwBMkFf7w0hmLIguLiEpzU= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= @@ -391,6 +393,8 @@ github.com/lmittmann/w3 v0.7.0 h1:z3Z7OeLAQ/oHWK8k5U5SH+G2BD6ZbsdrBktUytDzG78= github.com/lmittmann/w3 v0.7.0/go.mod h1:8F9LNaa0D/k68rnCdTtBsMon8ucpvlS9fmPVXp7Uz7M= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mapaiva/vcard-go v1.2.0 h1:w/Jmdow8CDE3T/6SBSTt1HBTDxZmQ39a415hCzElAN8= +github.com/mapaiva/vcard-go v1.2.0/go.mod h1:OedyhOJ8gUHmfp6iI3+2bf0kzQetFRpD625G9NCEB6Y= github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= diff --git a/internal/admin/api.go b/internal/admin/api.go new file mode 100644 index 0000000..633a309 --- /dev/null +++ b/internal/admin/api.go @@ -0,0 +1,82 @@ +package admin + +import ( + "net/http" + + "github.com/golang-jwt/jwt" + "github.com/grassrootseconomics/cic-go/meta" + "github.com/jackc/pgx/v4/pgxpool" + "github.com/labstack/echo/v4" + "github.com/nleof/goyesql" +) + +type api struct { + db *pgxpool.Pool + q goyesql.Queries + m *meta.CicMeta + jwtKey []byte +} + +func InitAdminApi(e *echo.Echo, db *pgxpool.Pool, queries goyesql.Queries, metaClient *meta.CicMeta, jwtKey string) { + api := newApi(db, queries, metaClient, jwtKey) + + auth := e.Group(("/auth")) + g := e.Group("/admin") + + auth.Use(api.dwCoreMiddleware) + auth.POST("/login", sendLoginJwtCookie) + auth.POST("/logout", sendLogoutCookie) + + g.Use(api.dwCoreMiddleware) + g.Use(api.verifyAuthMiddleware) + + g.GET("/check", isLoggedIn) + g.GET("/meta-proxy/:address", handleMetaProxy) + g.GET("/pin-status", handlePinStatus) + g.GET("/phone-2-address/:phone", handlePhone2Address) + g.GET("/address-2-phone/:address", handleAddress2Phone) +} + +func newApi(db *pgxpool.Pool, queries goyesql.Queries, metaClient *meta.CicMeta, jwtKey string) *api { + return &api{ + db: db, + q: queries, + m: metaClient, + jwtKey: []byte(jwtKey), + } +} + +func (a *api) dwCoreMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + c.Set("api", &api{ + db: a.db, + q: a.q, + m: a.m, + jwtKey: a.jwtKey, + }) + return next(c) + } +} + +func (a *api) verifyAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + cookie, err := c.Cookie("_ge_auth") + if err != nil { + return c.String(http.StatusForbidden, "auth cookie missing") + } + + claims := &jwtClaims{} + + token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (interface{}, error) { + return a.jwtKey, nil + }) + if err != nil { + return c.String(http.StatusUnauthorized, "jwt validation failed") + } + if !token.Valid { + return c.String(http.StatusUnauthorized, "jwt invalid") + } + + return next(c) + } +} diff --git a/internal/admin/auth.go b/internal/admin/auth.go new file mode 100644 index 0000000..7b037dd --- /dev/null +++ b/internal/admin/auth.go @@ -0,0 +1,88 @@ +package admin + +import ( + "context" + "net/http" + "time" + + "github.com/golang-jwt/jwt" + "github.com/labstack/echo/v4" + "golang.org/x/crypto/bcrypt" +) + +type staff struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type jwtClaims struct { + Username string `json:"username"` + jwt.StandardClaims +} + +func isLoggedIn(c echo.Context) error { + return c.String(http.StatusOK, "ok") +} + +func sendLoginJwtCookie(c echo.Context) error { + var ( + api = c.Get("api").(*api) + + passwordHash string + ) + + u := new(staff) + if err := c.Bind(u); err != nil { + return err + } + + if err := api.db.QueryRow(context.Background(), api.q["get-password-hash"], u.Username).Scan(&passwordHash); err != nil { + return c.String(http.StatusNotFound, "username not found") + } + + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(u.Password)); err != nil { + return c.String(http.StatusForbidden, "login failed") + } + + expiration := time.Now().Add(24 * 7 * time.Hour) + + claims := &jwtClaims{ + Username: u.Username, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expiration.Unix(), + }, + } + + jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := jwtToken.SignedString(api.jwtKey) + if err != nil { + return err + } + + cookie := cookieDefaults() + cookie.Value = tokenString + cookie.Expires = expiration + + c.SetCookie(cookie) + return c.String(http.StatusOK, "login successful") +} + +func sendLogoutCookie(c echo.Context) error { + cookie := cookieDefaults() + cookie.MaxAge = -1 + + c.SetCookie(cookie) + return c.String(http.StatusOK, "logout successful") +} + +func cookieDefaults() *http.Cookie { + cookie := new(http.Cookie) + + cookie.Name = "_ge_auth" + cookie.Path = "/" + cookie.SameSite = 3 + cookie.HttpOnly = true + cookie.Secure = false + + return cookie +} \ No newline at end of file diff --git a/internal/admin/pins.go b/internal/admin/pins.go new file mode 100644 index 0000000..28bebe4 --- /dev/null +++ b/internal/admin/pins.go @@ -0,0 +1,33 @@ +package admin + +import ( + "context" + "net/http" + + "github.com/georgysavva/scany/pgxscan" + "github.com/labstack/echo/v4" +) + +type pinStatusResponse struct { + PhoneNumber string `db:"phone_number" json:"phone_number"` + FailedPinAttempts int `db:"failed_pin_attempts" json:"failed_pin_attempts"` + AccountStatus string `db:"account_status" json:"account_status"` +} + +func handlePinStatus(c echo.Context) error { + var ( + api = c.Get("api").(*api) + res []pinStatusResponse + ) + + rows, err := api.db.Query(context.Background(), api.q["pin-status"]) + if err != nil { + return err + } + + if err := pgxscan.ScanAll(&res, rows); err != nil { + return err + } + + return c.JSON(http.StatusOK, res) +} diff --git a/internal/admin/user.go b/internal/admin/user.go new file mode 100644 index 0000000..0027cc4 --- /dev/null +++ b/internal/admin/user.go @@ -0,0 +1,95 @@ +package admin + +import ( + "bytes" + "cic-dw/pkg/address" + "context" + "encoding/base64" + "net/http" + "strings" + + "github.com/grassrootseconomics/cic-go/meta" + "github.com/labstack/echo/v4" + "github.com/mapaiva/vcard-go" +) + +type metaRes struct { + Person meta.PersonResponse `json:"person"` + Name string `json:"name"` +} + +func handlePhone2Address(c echo.Context) error { + var ( + api = c.Get("api").(*api) + phone = c.Param("phone") + + address string + ) + + if err := api.db.QueryRow(context.Background(), api.q["phone-2-address"], phone).Scan(&address); err != nil { + return c.String(http.StatusNotFound, "phone not found") + } + + return c.String(http.StatusOK, address) +} + +func handleAddress2Phone(c echo.Context) error { + var ( + api = c.Get("api").(*api) + + phone string + ) + + sarafuAddress, err := address.SarafuAddress(c.Param("address")) + if err != nil { + return err + } + + if err := api.db.QueryRow(context.Background(), api.q["address-2-phone"], sarafuAddress).Scan(&phone); err != nil { + return c.String(http.StatusNotFound, "address not found") + } + + return c.String(http.StatusOK, phone) +} + +func handleMetaProxy(c echo.Context) error { + var ( + api = c.Get("api").(*api) + address = c.Param("address") + ) + + person, err := api.m.GetPersonMetadata(address) + if err != nil { + if strings.Contains(err.Error(), "404") { + return c.String(http.StatusNotFound, "meta resource not found") + } else { + return err + } + } + + vCard, err := parseVCard(person.VCard) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, &metaRes{ + Person: person, + Name: vCard.FormattedName, + }) +} + +func parseVCard(encodedVCard string) (vcard.VCard, error) { + data, err := base64.StdEncoding.DecodeString(encodedVCard) + if err != nil { + return vcard.VCard{}, err + } + + reader := bytes.NewReader(data) + + vCards, err := vcard.GetVCardsByReader(reader) + if err != nil { + return vcard.VCard{}, nil + } + + return vCards[0], nil +} diff --git a/migrations/004_auth.sql b/migrations/004_auth.sql new file mode 100644 index 0000000..62573ef --- /dev/null +++ b/migrations/004_auth.sql @@ -0,0 +1,13 @@ +-- Drop static columns on user table +ALTER TABLE users DROP COLUMN failed_pin_attempts, DROP COLUMN ussd_account_status; + +-- Staff dashboard auth +CREATE TABLE IF NOT EXISTS staff ( + id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + username VARCHAR(16) NOT NULL UNIQUE, + password_hash VARCHAR(76) NOT NULL +) + +---- create above / drop below ---- + +DROP TABLE IF EXISTS staff; \ No newline at end of file diff --git a/queries/admin.sql b/queries/admin.sql new file mode 100644 index 0000000..fbba55a --- /dev/null +++ b/queries/admin.sql @@ -0,0 +1,18 @@ +-- name: get-password-hash +SELECT password_hash FROM staff WHERE username = $1; + +-- name: pin-status +SELECT phone_number, failed_pin_attempts, +CASE STATUS + WHEN 1 THEN 'PENDING' + WHEN 2 THEN 'ACTIVE' + WHEN 3 THEN 'LOCKED' + WHEN 4 THEN 'RESET' END AS account_status +FROM cic_ussd.account WHERE +failed_pin_attempts > 0 OR STATUS = 4; + +--name: phone-2-address +SELECT blockchain_address FROM users WHERE phone_number = $1; + +--name: address-2-phone +SELECT phone_number FROM users WHERE blockchain_address = $1; \ No newline at end of file diff --git a/queries/core.sql b/queries/core.sql index e6254e5..aa33421 100644 --- a/queries/core.sql +++ b/queries/core.sql @@ -7,8 +7,8 @@ WITH current_ussd_cursor AS ( SELECT id FROM cic_ussd.account WHERE blockchain_address = (SELECT cursor_pos FROM cursors WHERE id = 1) ) -INSERT INTO users (phone_number, blockchain_address, date_registered, failed_pin_attempts, ussd_account_status) -SELECT cic_ussd.account.phone_number, cic_ussd.account.blockchain_address, cic_ussd.account.created, cic_ussd.account.failed_pin_attempts, cic_ussd.account.status +INSERT INTO users (phone_number, blockchain_address, date_registered) +SELECT cic_ussd.account.phone_number, cic_ussd.account.blockchain_address, cic_ussd.account.created FROM cic_ussd.account WHERE cic_ussd.account.id > (SELECT id FROM current_ussd_cursor) ORDER BY cic_ussd.account.id ASC LIMIT 300; UPDATE cursors SET cursor_pos = (SELECT blockchain_address FROM users ORDER BY id DESC LIMIT 1) WHERE cursors.id = 1;