diff --git a/cmd/init.go b/cmd/init.go index c34f0e1..7a9f64e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -46,6 +46,7 @@ type queries struct { core goyesql.Queries dashboard goyesql.Queries public goyesql.Queries + admin goyesql.Queries } func loadConfig(configFilePath string, k *koanf.Koanf) error { @@ -137,10 +138,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/server.go b/cmd/server.go index 9f81bc5..029d78c 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, "test") return server } 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..03bc233 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 diff --git a/go.sum b/go.sum index 343a03e..fd9a641 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= diff --git a/internal/admin/api.go b/internal/admin/api.go new file mode 100644 index 0000000..b083c06 --- /dev/null +++ b/internal/admin/api.go @@ -0,0 +1,79 @@ +package admin + +import ( + "net/http" + + "github.com/golang-jwt/jwt" + "github.com/jackc/pgx/v4/pgxpool" + "github.com/labstack/echo/v4" + "github.com/nleof/goyesql" + "github.com/rs/zerolog/log" +) + +type api struct { + db *pgxpool.Pool + q goyesql.Queries + jwtKey []byte +} + +func InitAdminApi(e *echo.Echo, db *pgxpool.Pool, queries goyesql.Queries, jwtKey string) { + api := newApi(db, queries, 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("/protected", handleProtectedResource) +} + +func newApi(db *pgxpool.Pool, queries goyesql.Queries, jwtKey string) *api { + return &api{ + db: db, + q: queries, + 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, + jwtKey: a.jwtKey, + }) + return next(c) + } +} + +func (a *api) verifyAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + log.Info().Msgf("%v", c.Cookies()) + 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 { + if err == jwt.ErrSignatureInvalid { + return c.String(http.StatusUnauthorized, "jwt signature validation failed") + } + return c.String(http.StatusBadRequest, "jwt bad request") + } + 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..2f9cfc7 --- /dev/null +++ b/internal/admin/auth.go @@ -0,0 +1,78 @@ +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 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 := new(http.Cookie) + + cookie.Name = "_ge_auth" + cookie.Value = tokenString + cookie.Path = "/" + cookie.Expires = expiration + + c.SetCookie(cookie) + return c.String(http.StatusOK, "login successful") +} + +func sendLogoutCookie(c echo.Context) error { + cookie := new(http.Cookie) + + cookie.Name = "_ge_auth" + cookie.Value = "" + cookie.Expires = time.Now() + + c.SetCookie(cookie) + return c.String(http.StatusOK, "logout successful") +} diff --git a/internal/admin/user.go b/internal/admin/user.go new file mode 100644 index 0000000..16cf83a --- /dev/null +++ b/internal/admin/user.go @@ -0,0 +1,11 @@ +package admin + +import ( + "net/http" + + "github.com/labstack/echo/v4" +) + +func handleProtectedResource(c echo.Context) error { + return c.String(http.StatusOK, "unlocked") +} 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..489ad28 --- /dev/null +++ b/queries/admin.sql @@ -0,0 +1,2 @@ +-- name: get-password-hash +SELECT password_hash from staff WHERE username = $1; \ No newline at end of file