Procházet zdrojové kódy

Merge pull request #17 from porter-dev/authentication

Authentication Merge
abelanger5 před 5 roky
rodič
revize
f054fb86f2

+ 3 - 3
.air.toml

@@ -7,11 +7,11 @@ tmp_dir = "tmp"
 
 [build]
 # Just plain old shell command. You could use `make` as well.
-cmd = "go build -o ./tmp/app ./cmd/app"
+cmd = "go build -o ./tmp/migrate ./cmd/migrate; go build -o ./tmp/app ./cmd/app"
 # Binary file yields from `cmd`.
-bin = "tmp/app"
+bin = "tmp/migrate; tmp/app"
 # Customize binary.
-full_bin = "tmp/app"
+full_bin = "tmp/migrate; tmp/app"
 # Watch these filename extensions.
 include_ext = ["go", "mod", "sum", "html"]
 # Ignore these filename extensions or directories.

+ 3 - 0
INT_TEST.md

@@ -9,4 +9,7 @@ curl -d "{\"email\":\"hello\",\"password\":\"hello\"}" -H 'Content-Type: applica
 
 # should pass (without authentication)
 curl -d "{\"email\":\"belanger@getporter.dev\",\"password\":\"hello\"}" -H 'Content-Type: application/json' -X POST localhost:8080/api/users
+
+# should pass
+curl -X DELETE localhost:8080/api/users/1 -d "{\"password\":\"hello\"}"
 ```

binární
app


+ 8 - 1
cmd/app/main.go

@@ -5,9 +5,12 @@ import (
 	"log"
 	"net/http"
 
+	"github.com/porter-dev/porter/internal/repository/gorm"
+
 	"github.com/porter-dev/porter/server/api"
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
+	sessionstore "github.com/porter-dev/porter/internal/auth/"
 	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
 	vr "github.com/porter-dev/porter/internal/validator"
@@ -25,9 +28,13 @@ func main() {
 		return
 	}
 
+	key = []byte("secret") // TODO: change to os.Getenv("SESSION_KEY")
+	store, _ = sessionstore.NewStore(db, key)
+
 	validator := vr.New()
+	repo := gorm.NewRepository(db)
 
-	a := api.New(logger, db, validator)
+	a := api.New(logger, repo, validator, store)
 
 	appRouter := router.New(a)
 

+ 5 - 1
cmd/migrate/main.go

@@ -22,8 +22,12 @@ func main() {
 		return
 	}
 
-	db.AutoMigrate(
+	err = db.AutoMigrate(
 		&models.User{},
 		&models.ClusterConfig{},
 	)
+
+	if err != nil {
+		panic(err)
+	}
 }

+ 19 - 1
docker-compose.yaml

@@ -29,5 +29,23 @@ services:
     volumes:
       - db:/var/lib/postgresql/data
 
+  metabase:
+    image: metabase/metabase
+    restart: always
+    ports: 
+      - 3000:3000
+    volumes: 
+      - metabase:/metabase-data
+    environment:
+      MB_DB_TYPE: postgres
+      MB_DB_DBNAME: porter
+      MB_DB_PORT: 5432
+      MB_DB_USER: porter
+      MB_DB_PASS: porter
+      MB_DB_HOST: postgres
+    depends_on:
+      - postgres
+
 volumes:
-  db:
+  db:
+  metabase:

+ 9 - 3
go.mod

@@ -3,6 +3,7 @@ module github.com/porter-dev/porter
 go 1.14
 
 require (
+	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/cosmtrek/air v1.21.2 // indirect
 	github.com/creack/pty v1.1.11 // indirect
 	github.com/fatih/color v1.9.0 // indirect
@@ -10,22 +11,27 @@ require (
 	github.com/go-playground/locales v0.13.0
 	github.com/go-playground/universal-translator v0.17.0
 	github.com/go-playground/validator/v10 v10.3.0
+	github.com/go-test/deep v1.0.7
+	github.com/gorilla/securecookie v1.1.1
+	github.com/gorilla/sessions v1.2.1
 	github.com/imdario/mergo v0.3.11 // indirect
 	github.com/jinzhu/gorm v1.9.16
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
 	github.com/leodido/go-urn v1.2.0 // indirect
 	github.com/mattn/go-colorable v0.1.7 // indirect
 	github.com/pelletier/go-toml v1.8.1 // indirect
+	github.com/pkg/errors v0.9.1
 	github.com/rs/zerolog v1.20.0
-	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a // indirect
+	github.com/stretchr/testify v1.5.1
+	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
 	golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect
 	golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d // indirect
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
 	gopkg.in/go-playground/validator.v9 v9.31.0
 	gopkg.in/yaml.v2 v2.3.0
-	gorm.io/driver/postgres v1.0.1
-	gorm.io/gorm v1.20.1
+	gorm.io/driver/postgres v1.0.2
+	gorm.io/gorm v1.20.2
 	k8s.io/apimachinery v0.19.2
 	k8s.io/client-go v0.0.0-20200917000235-cba7285b7f29
 	k8s.io/klog v1.0.0 // indirect

+ 27 - 0
go.sum

@@ -41,6 +41,8 @@ github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZ
 github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
+github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
 github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
 github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
@@ -108,6 +110,8 @@ github.com/go-playground/validator/v10 v10.3.0 h1:nZU+7q+yJoFmwvNgv/LnPUkwPal62+
 github.com/go-playground/validator/v10 v10.3.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
+github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
 github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls=
 github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
@@ -164,6 +168,10 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gnostic v0.4.1 h1:DLJCy1n/vrD4HPjOvYcT8aYQXpPIzoRZONaYwyycI+I=
 github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
+github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -186,6 +194,8 @@ github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr
 github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
 github.com/jackc/pgconn v1.6.4 h1:S7T6cx5o2OqmxdHaXLH1ZeD1SbI8jBznyYE9Ec0RCQ8=
 github.com/jackc/pgconn v1.6.4/go.mod h1:w2pne1C2tZgP+TvjqLpOigGzNqjBgQW9dUw/4Chex78=
+github.com/jackc/pgconn v1.7.0 h1:pwjzcYyfmz/HQOQlENvG1OcDqauTGaqlVahq934F0/U=
+github.com/jackc/pgconn v1.7.0/go.mod h1:sF/lPpNEMEOp+IYhyQGdAvrG20gWf6A1tKlr0v7JMeA=
 github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
 github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
 github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
@@ -200,6 +210,8 @@ github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:
 github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
 github.com/jackc/pgproto3/v2 v2.0.2 h1:q1Hsy66zh4vuNsajBUF2PNqfAMMfxU5mk594lPE9vjY=
 github.com/jackc/pgproto3/v2 v2.0.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgproto3/v2 v2.0.5 h1:NUbEWPmCQZbMmYlTjVoNPhc0CfnYyz2bfUAh6A5ZVJM=
+github.com/jackc/pgproto3/v2 v2.0.5/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
 github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
 github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
 github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
@@ -211,6 +223,8 @@ github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkAL
 github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
 github.com/jackc/pgtype v1.4.2 h1:t+6LWm5eWPLX1H5Se702JSBcirq6uWa4jiG4wV1rAWY=
 github.com/jackc/pgtype v1.4.2/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
+github.com/jackc/pgtype v1.5.0 h1:jzBqRk2HFG2CV4AIwgCI2PwTgm6UUoCAK2ofHHRirtc=
+github.com/jackc/pgtype v1.5.0/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
 github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
 github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
 github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
@@ -219,10 +233,13 @@ github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6
 github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
 github.com/jackc/pgx/v4 v4.8.1 h1:SUbCLP2pXvf/Sr/25KsuI4aTxiFYIvpfk4l6aTSdyCw=
 github.com/jackc/pgx/v4 v4.8.1/go.mod h1:4HOLxrl8wToZJReD04/yB20GDwf4KBYETvlHciCnwW0=
+github.com/jackc/pgx/v4 v4.9.0 h1:6STjDqppM2ROy5p1wNDcsC7zJTjSHeuCsguZmXyzx7c=
+github.com/jackc/pgx/v4 v4.9.0/go.mod h1:MNGWmViCgqbZck9ujOOBN63gK9XVGILXWCvKLGKmnms=
 github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
 github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
 github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -289,7 +306,9 @@ github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNC
 github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
 github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -309,10 +328,12 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -619,8 +640,14 @@ gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gorm.io/driver/postgres v1.0.1 h1:jRfDNUxpxNrea/97kbcscAQGmiks4UCKAYXsvh4rhOQ=
 gorm.io/driver/postgres v1.0.1/go.mod h1:pv4dVhHvEVrP7k/UYqdBIllbdbpB5VTz89X1O0uOrCA=
+gorm.io/driver/postgres v1.0.2 h1:mB5JjD4QglbCTdMT1aZDxQzHr87XDK1qh0MKIU3P96g=
+gorm.io/driver/postgres v1.0.2/go.mod h1:FvRSYfBI9jEp6ZSjlpS9qNcSjxwYxFc03UOTrHdvvYA=
+gorm.io/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
+gorm.io/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
 gorm.io/gorm v1.20.1 h1:+hOwlHDqvqmBIMflemMVPLJH7tZYK4RxFDBHEfJTup0=
 gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
+gorm.io/gorm v1.20.2 h1:bZzSEnq7NDGsrd+n3evOOedDrY5oLM5QPlCjZJUK2ro=
+gorm.io/gorm v1.20.2/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

+ 0 - 1
internal/adapter/gorm.go

@@ -9,7 +9,6 @@ import (
 )
 
 // New returns a new gorm database instance
-// TODO -- accept config to generate connection
 func New(conf *config.DBConf) (*gorm.DB, error) {
 	dsn := fmt.Sprintf(
 		"user=%s password=%s port=%d host=%s sslmode=disable",

+ 62 - 0
internal/auth/example/authExample.go

@@ -0,0 +1,62 @@
+package main
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/config"
+
+	dbConn "github.com/porter-dev/porter/internal/adapter"
+	sessionstore "github.com/porter-dev/porter/internal/auth"
+)
+
+var appConf = config.AppConfig()
+
+var db, dbErr = dbConn.New(&appConf.Db)
+
+var (
+	key      = []byte("secret") // change to os.Getenv("SESSION_KEY")
+	store, _ = sessionstore.NewStore(db, key)
+)
+
+func secret(w http.ResponseWriter, r *http.Request) {
+
+	session, _ := store.Get(r, "cookie-name")
+	fmt.Println(session.Values["authenticated"])
+
+	// Check if user is authenticated
+	if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
+		http.Error(w, "Forbidden", http.StatusForbidden)
+		return
+	}
+
+	// Print secret message
+	fmt.Fprintln(w, "The cake is a lie!")
+}
+
+func login(w http.ResponseWriter, r *http.Request) {
+	session, _ := store.Get(r, "cookie-name")
+
+	// Authentication goes here
+	// ...
+
+	// Set user as authenticated
+	session.Values["authenticated"] = true
+	session.Save(r, w)
+}
+
+func logout(w http.ResponseWriter, r *http.Request) {
+	session, _ := store.Get(r, "cookie-name")
+
+	// Revoke users authentication
+	session.Values["authenticated"] = false
+	session.Save(r, w)
+}
+
+func main() {
+	http.HandleFunc("/secret", secret)
+	http.HandleFunc("/login", login)
+	http.HandleFunc("/logout", logout)
+
+	http.ListenAndServe(":8080", nil)
+}

+ 195 - 0
internal/auth/sessionstore.go

@@ -0,0 +1,195 @@
+// Package sessionstore is a postgresql backend implementation of gorilla/sessions Session interface, based on
+// antonlindstrom/pgstore. Key change is to use GORM instead of typical sql driver using queries.
+package sessionstore
+
+import (
+	"database/sql"
+	"encoding/base32"
+	"net/http"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"github.com/gorilla/securecookie"
+	"github.com/gorilla/sessions"
+	"github.com/pkg/errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	rp "github.com/porter-dev/porter/internal/repository/gorm"
+)
+
+// structs
+
+// PGStore is a wrapper around gorilla/sessions store.
+type PGStore struct {
+	Codecs  []securecookie.Codec
+	Options *sessions.Options
+	Path    string
+	DbPool  *gorm.DB
+}
+
+// Helpers
+
+// MaxLength restricts the maximum length of new sessions to l.
+// If l is 0 there is no limit to the size of a session, use with caution.
+// The default for a new PGStore is 4096. PostgreSQL allows for max
+// value sizes of up to 1GB (http://www.postgresql.org/docs/current/interactive/datatype-character.html)
+func (db *PGStore) MaxLength(l int) {
+	for _, c := range db.Codecs {
+		if codec, ok := c.(*securecookie.SecureCookie); ok {
+			codec.MaxLength(l)
+		}
+	}
+}
+
+// MaxAge sets the maximum age for the store and the underlying cookie
+// implementation. Individual sessions can be deleted by setting Options.MaxAge
+// = -1 for that session.
+func (db *PGStore) MaxAge(age int) {
+	db.Options.MaxAge = age
+
+	// Set the maxAge for each securecookie instance.
+	for _, codec := range db.Codecs {
+		if sc, ok := codec.(*securecookie.SecureCookie); ok {
+			sc.MaxAge(age)
+		}
+	}
+}
+
+// load fetches a session by ID from the database and decodes its content
+// into session.Values.
+func (db *PGStore) load(session *sessions.Session) error {
+	repo := rp.NewRepository(db.DbPool)
+	res, err := repo.Session.SelectSession(&models.Session{Key: session.ID})
+
+	if err != nil {
+		return err
+	}
+
+	return securecookie.DecodeMulti(session.Name(), string(res.Data), &session.Values, db.Codecs...)
+}
+
+// save writes encoded session.Values to a database record.
+// writes to http_sessions table by default.
+func (db *PGStore) save(session *sessions.Session) error {
+	encoded, err := securecookie.EncodeMulti(session.Name(), session.Values, db.Codecs...)
+	if err != nil {
+		return err
+	}
+
+	exOn := session.Values["expires_on"]
+
+	var expiresOn time.Time
+
+	if exOn == nil {
+		expiresOn = time.Now().Add(time.Second * time.Duration(session.Options.MaxAge))
+	} else {
+		expiresOn = exOn.(time.Time)
+		if expiresOn.Sub(time.Now().Add(time.Second*time.Duration(session.Options.MaxAge))) < 0 {
+			expiresOn = time.Now().Add(time.Second * time.Duration(session.Options.MaxAge))
+		}
+	}
+
+	s := models.Session{
+		Key:       session.ID,
+		Data:      []byte(encoded),
+		ExpiresAt: expiresOn,
+	}
+
+	repo := rp.NewRepository(db.DbPool)
+
+	if session.IsNew {
+		_, createErr := repo.Session.CreateSession(&s)
+		return createErr
+	}
+
+	_, updateErr := repo.Session.UpdateSession(&s)
+	return updateErr
+}
+
+// Implementation of the interface (Get, New, Save)
+
+// NewStore takes an initialized db and session key pairs to create a session-store in postgres db.
+func NewStore(db *gorm.DB, keyPairs ...[]byte) (*PGStore, error) {
+	dbStore := &PGStore{
+		Codecs: securecookie.CodecsFromPairs(keyPairs...),
+		Options: &sessions.Options{
+			Path:   "/",
+			MaxAge: 86400 * 30,
+		},
+		DbPool: db,
+	}
+
+	return dbStore, nil
+}
+
+// Get Fetches a session for a given name after it has been added to the
+// registry.
+func (db *PGStore) Get(r *http.Request, name string) (*sessions.Session, error) {
+	return sessions.GetRegistry(r).Get(db, name)
+}
+
+// New returns a new session for the given name without adding it to the registry.
+func (db *PGStore) New(r *http.Request, name string) (*sessions.Session, error) {
+	session := sessions.NewSession(db, name)
+	if session == nil {
+		return nil, nil
+	}
+
+	opts := *db.Options
+	session.Options = &(opts)
+	session.IsNew = true
+
+	var err error
+	if c, errCookie := r.Cookie(name); errCookie == nil {
+		err = securecookie.DecodeMulti(name, c.Value, &session.ID, db.Codecs...)
+		if err == nil {
+			err = db.load(session)
+			if err == nil {
+				session.IsNew = false
+			} else if errors.Cause(err) == sql.ErrNoRows {
+				err = nil
+			}
+		}
+	}
+
+	db.MaxAge(db.Options.MaxAge)
+
+	return session, err
+}
+
+// Save saves the given session into the database and deletes cookies if needed
+func (db *PGStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
+	repo := rp.NewRepository(db.DbPool)
+
+	// Set delete if max-age is < 0
+	if session.Options.MaxAge < 0 {
+		if _, err := repo.Session.DeleteSession(&models.Session{Key: session.ID}); err != nil {
+			return err
+		}
+		http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options))
+		return nil
+	}
+
+	if session.ID == "" {
+		// Generate a random session ID key suitable for storage in the DB
+		session.ID = strings.TrimRight(
+			base32.StdEncoding.EncodeToString(
+				securecookie.GenerateRandomKey(32),
+			), "=")
+	}
+
+	if err := db.save(session); err != nil {
+		return err
+	}
+
+	// Keep the session ID key in a cookie so it can be looked up in DB later.
+	encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, db.Codecs...)
+	if err != nil {
+		return err
+	}
+
+	http.SetCookie(w, sessions.NewCookie(session.Name(), encoded, session.Options))
+	return nil
+}

+ 152 - 0
internal/auth/sessionstore_test.go

@@ -0,0 +1,152 @@
+package sessionstore
+
+import (
+	"encoding/base64"
+	"net/http"
+	"testing"
+
+	"github.com/gorilla/securecookie"
+	"github.com/gorilla/sessions"
+
+	dbConn "github.com/porter-dev/porter/internal/adapter"
+)
+
+type headerOnlyResponseWriter http.Header
+
+func (ho headerOnlyResponseWriter) Header() http.Header {
+	return http.Header(ho)
+}
+
+func (ho headerOnlyResponseWriter) Write([]byte) (int, error) {
+	panic("NOIMPL")
+}
+
+func (ho headerOnlyResponseWriter) WriteHeader(int) {
+	panic("NOIMPL")
+}
+
+var secret = "secret"
+
+func TestPGStore(t *testing.T) {
+	db, _ := dbConn.New()
+
+	ss, err := NewStore(db, []byte(secret))
+	if err != nil {
+		t.Fatal("Failed to get store", err)
+	}
+
+	// ROUND 1 - Check that the cookie is being saved
+	req, err := http.NewRequest("GET", "http://www.example.com", nil)
+	if err != nil {
+		t.Fatal("failed to create request", err)
+	}
+
+	session, err := ss.Get(req, "mysess")
+	if err != nil {
+		t.Fatal("failed to get session", err.Error())
+	}
+
+	session.Values["counter"] = 1
+
+	m := make(http.Header)
+	if err = ss.Save(req, headerOnlyResponseWriter(m), session); err != nil {
+		t.Fatal("Failed to save session:", err.Error())
+	}
+
+	if m["Set-Cookie"][0][0:6] != "mysess" {
+		t.Fatal("Cookie wasn't set!")
+	}
+
+	// ROUND 2 - check that the cookie can be retrieved
+	req, err = http.NewRequest("GET", "http://www.example.com", nil)
+	if err != nil {
+		t.Fatal("failed to create round 2 request", err)
+	}
+
+	encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, ss.Codecs...)
+	if err != nil {
+		t.Fatal("Failed to make cookie value", err)
+	}
+
+	req.AddCookie(sessions.NewCookie(session.Name(), encoded, session.Options))
+
+	session, err = ss.Get(req, "mysess")
+	if err != nil {
+		t.Fatal("failed to get round 2 session", err.Error())
+	}
+
+	if session.Values["counter"] != 1 {
+		t.Fatal("Retrieved session had wrong value:", session.Values["counter"])
+	}
+
+	session.Values["counter"] = 9 // set new value for round 3
+	if err = ss.Save(req, headerOnlyResponseWriter(m), session); err != nil {
+		t.Fatal("Failed to save session:", err.Error())
+	}
+
+	// ROUND 2 - check that the cookie has been updated
+	req, err = http.NewRequest("GET", "http://www.example.com", nil)
+	if err != nil {
+		t.Fatal("failed to create round 3 request", err)
+	}
+	req.AddCookie(sessions.NewCookie(session.Name(), encoded, session.Options))
+
+	session, err = ss.Get(req, "mysess")
+	if err != nil {
+		t.Fatal("failed to get session round 3", err.Error())
+	}
+
+	if session.Values["counter"] != 9 {
+		t.Fatal("Retrieved session had wrong value in round 3:", session.Values["counter"])
+	}
+
+	// ROUND 3 - Increase max length
+	req, err = http.NewRequest("GET", "http://www.example.com", nil)
+	if err != nil {
+		t.Fatal("failed to create round 3 request", err)
+	}
+
+	req.AddCookie(sessions.NewCookie(session.Name(), encoded, session.Options))
+	session, err = ss.New(req, "my session")
+	if err != nil {
+		t.Fatal("failed to create session", err)
+	}
+
+	session.Values["big"] = make([]byte, base64.StdEncoding.DecodedLen(4096*2))
+
+	if err = ss.Save(req, headerOnlyResponseWriter(m), session); err == nil {
+		t.Fatal("expected an error, got nil")
+	}
+
+	ss.MaxLength(4096 * 3) // A bit more than the value size to account for encoding overhead.
+	if err = ss.Save(req, headerOnlyResponseWriter(m), session); err != nil {
+		t.Fatal("Failed to save session:", err.Error())
+	}
+}
+
+func TestSessionOptionsAreUniquePerSession(t *testing.T) {
+	db, _ := dbConn.New()
+
+	ss, err := NewStore(db, []byte(secret))
+	if err != nil {
+		t.Fatal("Failed to get store", err)
+	}
+
+	ss.Options.MaxAge = 900
+
+	req, err := http.NewRequest("GET", "http://www.example.com", nil)
+	if err != nil {
+		t.Fatal("Failed to create request", err)
+	}
+
+	session, err := ss.Get(req, "newsess")
+	if err != nil {
+		t.Fatal("Failed to create session", err)
+	}
+
+	session.Options.MaxAge = -1
+
+	if ss.Options.MaxAge != 900 {
+		t.Fatalf("PGStore.Options.MaxAge: expected %d, got %d", 900, ss.Options.MaxAge)
+	}
+}

+ 83 - 0
internal/forms/user.go

@@ -0,0 +1,83 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"golang.org/x/crypto/bcrypt"
+	"gopkg.in/yaml.v2"
+	"gorm.io/gorm"
+)
+
+// WriteUserForm is a generic form for write operations to the User model
+type WriteUserForm interface {
+	ToUser() (*models.User, error)
+}
+
+// CreateUserForm represents the accepted values for creating a user
+type CreateUserForm struct {
+	WriteUserForm
+	Email    string `json:"email" form:"required,max=255,email"`
+	Password string `json:"password" form:"required,max=255"`
+}
+
+// ToUser converts a CreateUserForm to models.User
+func (cuf *CreateUserForm) ToUser() (*models.User, error) {
+	hashed, err := bcrypt.GenerateFromPassword([]byte(cuf.Password), 8)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.User{
+		Email:    cuf.Email,
+		Password: string(hashed),
+	}, nil
+}
+
+// UpdateUserForm represents the accepted values for updating a user
+//
+// ID is a query parameter, the other two are sent in JSON body
+type UpdateUserForm struct {
+	WriteUserForm
+	ID              uint     `form:"required"`
+	RawKubeConfig   string   `json:"rawKubeConfig" form:"required"`
+	AllowedClusters []string `json:"allowedClusters" form:"required"`
+}
+
+// ToUser converts an UpdateUserForm to models.User by parsing the kubeconfig
+// and the allowed clusters to generate a list of ClusterConfigs.
+func (uuf *UpdateUserForm) ToUser() (*models.User, error) {
+	conf := kubernetes.KubeConfig{}
+	rawBytes := []byte(uuf.RawKubeConfig)
+	err := yaml.Unmarshal(rawBytes, &conf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	clusters := conf.ToClusterConfigs(uuf.AllowedClusters)
+
+	return &models.User{
+		Model: gorm.Model{
+			ID: uuf.ID,
+		},
+		Clusters:      clusters,
+		RawKubeConfig: rawBytes,
+	}, nil
+}
+
+// DeleteUserForm represents the accepted values for deleting a user
+type DeleteUserForm struct {
+	WriteUserForm
+	ID       uint   `form:"required"`
+	Password string `json:"password" form:"required,max=255"`
+}
+
+// ToUser converts a DeleteUserForm to models.User using the user ID
+func (uuf *DeleteUserForm) ToUser() (*models.User, error) {
+	return &models.User{
+		Model: gorm.Model{
+			ID: uuf.ID,
+		},
+	}, nil
+}

+ 32 - 5
internal/kubernetes/kubeconfig.go

@@ -1,6 +1,10 @@
 package kubernetes
 
-import "github.com/porter-dev/porter/internal/models"
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/models"
+)
 
 // KubeConfigCluster represents the cluster field in a kubeconfig
 type KubeConfigCluster struct {
@@ -34,14 +38,21 @@ type KubeConfig struct {
 
 // ToClusterConfigs converts a KubeConfig to a set of ClusterConfigExternals by
 // joining users and clusters on the context.
-func (k *KubeConfig) ToClusterConfigs() []*models.ClusterConfigExternal {
-	clusters := make([]*models.ClusterConfigExternal, 0)
+//
+// It accepts a list of cluster names that the user wishes to connect to
+func (k *KubeConfig) ToClusterConfigs(allowedClusters []string) []models.ClusterConfig {
+	clusters := make([]models.ClusterConfig, 0)
 
 	// convert clusters, contexts, and users to maps for fast lookup
 	clusterMap := k.createClusterMap()
 	contextMap := k.createContextMap()
 	userMap := k.createUserMap()
 
+	// put allowed clusters in map
+	aClusterMap := createAllowedClusterMap(allowedClusters)
+
+	fmt.Println(allowedClusters, aClusterMap)
+
 	// iterate through context maps and link to a user-cluster pair
 	for contextName, context := range contextMap {
 		userName := context.Context.User
@@ -49,8 +60,13 @@ func (k *KubeConfig) ToClusterConfigs() []*models.ClusterConfigExternal {
 		_, userFound := userMap[userName]
 		cluster, clusterFound := clusterMap[clusterName]
 
-		if userFound && clusterFound {
-			clusters = append(clusters, &models.ClusterConfigExternal{
+		// make sure the cluster is "allowed"
+		_, aClusterFound := aClusterMap[clusterName]
+
+		fmt.Println(userFound, clusterFound, aClusterFound)
+
+		if userFound && clusterFound && aClusterFound {
+			clusters = append(clusters, models.ClusterConfig{
 				Name:    clusterName,
 				Server:  cluster.Cluster.Server,
 				Context: contextName,
@@ -62,6 +78,17 @@ func (k *KubeConfig) ToClusterConfigs() []*models.ClusterConfigExternal {
 	return clusters
 }
 
+// createAllowedClusterMap creates a map from a cluster name to a KubeConfigCluster object
+func createAllowedClusterMap(clusters []string) map[string]string {
+	aClusterMap := make(map[string]string)
+
+	for _, cluster := range clusters {
+		aClusterMap[cluster] = cluster
+	}
+
+	return aClusterMap
+}
+
 // createClusterMap creates a map from a cluster name to a KubeConfigCluster object
 func (k *KubeConfig) createClusterMap() map[string]KubeConfigCluster {
 	clusterMap := make(map[string]KubeConfigCluster)

+ 243 - 0
internal/kubernetes/kubeconfig_test.go

@@ -0,0 +1,243 @@
+package kubernetes_test
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"gopkg.in/yaml.v2"
+)
+
+type KubeConfigTest struct {
+	msg             string
+	raw             []byte
+	allowedClusters []string
+	expected        []models.ClusterConfig
+}
+
+var MissingFieldsTest = []KubeConfigTest{
+	KubeConfigTest{
+		msg:             "no fields at all",
+		raw:             []byte(""),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+	KubeConfigTest{
+		msg:             "no contexts to join",
+		raw:             []byte(noContexts),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+	KubeConfigTest{
+		msg:             "no clusters to join",
+		raw:             []byte(noClusters),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+	KubeConfigTest{
+		msg:             "no users to join",
+		raw:             []byte(noUsers),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+	KubeConfigTest{
+		msg:             "no cluster contexts to join",
+		raw:             []byte(noContextClusters),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+	KubeConfigTest{
+		msg:             "no cluster users to join",
+		raw:             []byte(noContextUsers),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+}
+
+func TestToClusterConfigsMissingFields(t *testing.T) {
+	for _, c := range MissingFieldsTest {
+		// take raw and decode
+		conf := kubernetes.KubeConfig{}
+		err := yaml.Unmarshal(c.raw, &conf)
+
+		if err != nil {
+			t.Errorf("Testing: %s, Error: %v\n", c.msg, err)
+		}
+
+		res := conf.ToClusterConfigs(c.allowedClusters)
+
+		isEqual := reflect.DeepEqual(c.expected, res)
+
+		if !isEqual {
+			t.Errorf("Testing: %s, Expected: %v, Got: %v\n", c.msg, c.expected, res)
+		}
+	}
+}
+
+var NoAllowedClustersTests = []KubeConfigTest{
+	KubeConfigTest{
+		msg:             "basic test",
+		raw:             []byte(basic),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+}
+
+func TestToClusterConfigsNoAllowedClusters(t *testing.T) {
+	for _, c := range NoAllowedClustersTests {
+		// take raw and decode
+
+		conf := kubernetes.KubeConfig{}
+		err := yaml.Unmarshal(c.raw, &conf)
+
+		if err != nil {
+			t.Errorf("Testing: %s, Error: %v\n", c.msg, err)
+		}
+
+		res := conf.ToClusterConfigs(c.allowedClusters)
+
+		isEqual := reflect.DeepEqual(c.expected, res)
+
+		if !isEqual {
+			t.Errorf("Testing: %s, Expected: %v, Got: %v\n", c.msg, c.expected, res)
+		}
+	}
+}
+
+var BasicClustersTests = []KubeConfigTest{
+	KubeConfigTest{
+		msg:             "basic test",
+		raw:             []byte(basic),
+		allowedClusters: []string{"cluster-test"},
+		expected: []models.ClusterConfig{
+			models.ClusterConfig{
+				Name:    "cluster-test",
+				Server:  "https://localhost",
+				Context: "context-test",
+				User:    "test-admin",
+			},
+		},
+	},
+}
+
+func TestToClusterConfigsBasic(t *testing.T) {
+	for _, c := range BasicClustersTests {
+		// take raw and decode
+		conf := kubernetes.KubeConfig{}
+		err := yaml.Unmarshal(c.raw, &conf)
+
+		if err != nil {
+			t.Errorf("Testing: %s, Error: %v\n", c.msg, err)
+		}
+
+		res := conf.ToClusterConfigs(c.allowedClusters)
+
+		isEqual := reflect.DeepEqual(c.expected, res)
+
+		if !isEqual {
+			t.Errorf("Testing: %s, Expected: %v, Got: %v\n", c.msg, c.expected, res)
+		}
+	}
+}
+
+const noContexts string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+clusters:
+- cluster:
+    server: https://localhost
+  name: porter-test-1
+current-context: default
+users:
+- name: test-admin
+  user:
+`
+
+const noClusters string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: default
+contexts:
+- context:
+    cluster: porter-test-1
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+`
+
+const noUsers string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: default
+clusters:
+- cluster:
+    server: https://localhost
+  name: porter-test-1
+contexts:
+- context:
+    cluster: porter-test-1
+    user: test-admin
+  name: context-test
+`
+
+const noContextClusters string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: default
+clusters:
+- cluster:
+    server: https://localhost
+  name: porter-test-1
+contexts:
+- context:
+    # cluster: porter-test-1
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+`
+
+const noContextUsers string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: default
+clusters:
+- cluster:
+    server: https://localhost
+  name: porter-test-1
+contexts:
+- context:
+    cluster: porter-test-1
+    # user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+`
+
+const basic string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: default
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+  - name: test-admin
+`

+ 1 - 1
internal/models/cluster_configs.go

@@ -1,6 +1,6 @@
 package models
 
-import "github.com/jinzhu/gorm"
+import "gorm.io/gorm"
 
 // ClusterConfig that extends gorm.Model
 //

+ 38 - 0
internal/models/cluster_configs_test.go

@@ -0,0 +1,38 @@
+package models_test
+
+import (
+	"testing"
+
+	"gorm.io/gorm"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func TestClusterConfigExternalize(t *testing.T) {
+	cc := &models.ClusterConfig{
+		Model: gorm.Model{
+			ID: 1,
+		},
+		Name:   "test",
+		Server: "localhost",
+		User:   "test",
+		UserID: 1,
+	}
+
+	extCC := *cc.Externalize()
+
+	if extCC.Name != cc.Name {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Name", extCC.Name, cc.Name)
+	}
+
+	if extCC.Server != cc.Server {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Server", extCC.Server, cc.Server)
+	}
+
+	if extCC.User != cc.User {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "User", extCC.User, cc.User)
+	}
+
+	if extCC.Context != cc.Context {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Context", extCC.Context, cc.Context)
+	}
+}

+ 18 - 0
internal/models/session.go

@@ -0,0 +1,18 @@
+package models
+
+import (
+	"time"
+
+	"github.com/jinzhu/gorm"
+)
+
+// Session type that extends gorm.Model.
+type Session struct {
+	gorm.Model
+	// Session ID
+	Key string
+	// encrypted cookie
+	Data []byte
+	// Time the session will expire
+	ExpiresAt time.Time
+}

+ 3 - 23
internal/models/user.go

@@ -1,14 +1,15 @@
 package models
 
 import (
-	"github.com/jinzhu/gorm"
+	"gorm.io/gorm"
 )
 
 // User type that extends gorm.Model
 type User struct {
 	gorm.Model
 	// Unique email for each user
-	Email,
+	// Email string `gorm:"unique"`
+	Email string
 	// Hashed password
 	Password string
 	// The clusters that this user has linked
@@ -25,17 +26,6 @@ type UserExternal struct {
 	RawKubeConfig string                   `json:"rawKubeConfig"`
 }
 
-// CreateUserForm represents the accepted values for creating a user
-type CreateUserForm struct {
-	Email    string `json:"email" form:"required,max=255,email"`
-	Password string `json:"password" form:"required,max=255"`
-}
-
-// UpdateUserForm represents the accepted values for updating a user
-type UpdateUserForm struct {
-	RawKubeConfig string `json:"rawKubeConfig"`
-}
-
 // Externalize generates an external User to be shared over REST
 func (u *User) Externalize() *UserExternal {
 	clustersExt := make([]*ClusterConfigExternal, 0)
@@ -51,13 +41,3 @@ func (u *User) Externalize() *UserExternal {
 		RawKubeConfig: string(u.RawKubeConfig),
 	}
 }
-
-// ToUser converts a user form to a user
-//
-// TODO -- PASSWORD HASHING HERE
-func (cuf *CreateUserForm) ToUser() (*User, error) {
-	return &User{
-		Email:    cuf.Email,
-		Password: cuf.Password,
-	}, nil
-}

+ 46 - 0
internal/models/user_test.go

@@ -0,0 +1,46 @@
+package models_test
+
+import (
+	"testing"
+
+	"gorm.io/gorm"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func TestUserExternalize(t *testing.T) {
+	// create a new user
+	user := &models.User{
+		Model: gorm.Model{
+			ID: 1,
+		},
+		Email:    "testing@testing.com",
+		Password: "testing123",
+		Clusters: []models.ClusterConfig{
+			models.ClusterConfig{
+				Name:   "test",
+				Server: "localhost",
+				User:   "test",
+				UserID: 1,
+			},
+		},
+		RawKubeConfig: []byte{},
+	}
+
+	extUser := *user.Externalize()
+
+	if extUser.ID != user.ID {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "ID", user.ID, extUser.ID)
+	}
+
+	if extUser.Email != user.Email {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Email", user.Email, extUser.Email)
+	}
+
+	if len(extUser.Clusters) != 1 {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Length Clusters", len(extUser.Clusters), 1)
+	}
+
+	if len(extUser.RawKubeConfig) != 0 {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Length RawKubeConfig", len(extUser.RawKubeConfig), 0)
+	}
+}

+ 0 - 23
internal/queries/user.go

@@ -1,23 +0,0 @@
-package queries
-
-import (
-	"github.com/porter-dev/porter/internal/models"
-	"gorm.io/gorm"
-)
-
-// CreateUser adds a new User row to the Users table in the database
-func CreateUser(db *gorm.DB, user *models.User) (*models.User, error) {
-	if err := db.Create(user).Error; err != nil {
-		return nil, err
-	}
-	return user, nil
-}
-
-// UpdateUser modifies an existing User in the database
-func UpdateUser(db *gorm.DB, user *models.User) error {
-	if err := db.First(&models.User{}, user.ID).Updates(user).Error; err != nil {
-		return err
-	}
-
-	return nil
-}

+ 15 - 0
internal/repository/gorm/repository.go

@@ -0,0 +1,15 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// NewRepository returns a Repository which uses
+// gorm.DB for querying the database
+func NewRepository(db *gorm.DB) *repository.Repository {
+	return &repository.Repository{
+		User:    NewUserRepository(db),
+		Session: NewSessionRepository(db),
+	}
+}

+ 55 - 0
internal/repository/gorm/session.go

@@ -0,0 +1,55 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+type sessionrepo struct {
+	db *gorm.DB
+}
+
+// NewSessionRepository returns pointer to repo along with the db
+func NewSessionRepository(db *gorm.DB) repository.SessionRepository {
+	return &sessionrepo{
+		db: db,
+	}
+}
+
+// CreateSession must take in Key, Data, and ExpiresAt as arguments.
+func (s *sessionrepo) CreateSession(session *models.Session) (*models.Session, error) {
+	// TODO: check for duplicate and return error
+	if err := s.db.Create(session).Error; err != nil {
+		return nil, err
+	}
+	return session, nil
+}
+
+// UpdateSession updates only the Data field using Key as selector.
+func (s *sessionrepo) UpdateSession(session *models.Session) (*models.Session, error) {
+	if err := s.db.Model(session).Where("Key = ?", session.Key).Updates(session).Error; err != nil {
+		return nil, err
+	}
+	return session, nil
+}
+
+// DeleteSession deletes a session by Key
+func (s *sessionrepo) DeleteSession(session *models.Session) (*models.Session, error) {
+
+	if err := s.db.Where("Key = ?", session.Key).Delete(session).Error; err != nil {
+		return nil, err
+	}
+
+	return session, nil
+}
+
+// SelectSession returns a session with matching key
+func (s *sessionrepo) SelectSession(session *models.Session) (*models.Session, error) {
+
+	if err := s.db.Where("Key = ?", session.Key).First(session).Error; err != nil {
+		return nil, err
+	}
+
+	return session, nil
+}

+ 147 - 0
internal/repository/gorm/session_test.go

@@ -0,0 +1,147 @@
+package gorm
+
+import (
+	"database/sql"
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/DATA-DOG/go-sqlmock"
+	"github.com/go-test/deep"
+	"github.com/jinzhu/gorm"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+)
+
+type Suite struct {
+	suite.Suite
+	db   *gorm.DB
+	mock sqlmock.Sqlmock
+
+	repo    repository.SessionRepository
+	session *models.Session
+}
+
+func (s *Suite) SetupSuite() {
+	var (
+		db  *sql.DB
+		err error
+	)
+
+	// TODO: make it work with gorm.io/gorm, currently only works with jinzhu/gorm (gorm V1)
+	db, s.mock, err = sqlmock.New()
+	// db, s.mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
+
+	require.NoError(s.T(), err)
+
+	s.db, err = gorm.Open("postgres", db)
+	require.NoError(s.T(), err)
+
+	s.db.LogMode(true)
+
+	s.repo = NewSessionRepository(s.db)
+}
+
+func (s *Suite) AfterTest(_, _ string) {
+	require.NoError(s.T(), s.mock.ExpectationsWereMet())
+}
+
+func TestInit(t *testing.T) {
+	suite.Run(t, new(Suite))
+}
+
+func (s *Suite) TestShouldCreateNewSession() {
+	var (
+		key       = "onekey"
+		data      = []byte("onedata")
+		expiresAt = time.Now()
+	)
+
+	rows := sqlmock.NewRows([]string{"id"}).AddRow("111")
+
+	s.mock.ExpectBegin()
+	s.mock.ExpectQuery(regexp.QuoteMeta(
+		`INSERT INTO "sessions" ("created_at","updated_at","deleted_at","key","data","expires_at")
+		VALUES ($1,$2,$3,$4,$5,$6) RETURNING "sessions"."id"`)).
+		WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), key, data, expiresAt).
+		WillReturnRows(rows)
+	s.mock.ExpectCommit()
+
+	// test function
+	_, err := s.repo.CreateSession(&models.Session{
+		Key:       key,
+		Data:      data,
+		ExpiresAt: expiresAt,
+	})
+
+	require.NoError(s.T(), err)
+}
+
+func (s *Suite) TestShoudSelectSessionByKey() {
+	var (
+		key = "onekey"
+	)
+
+	rows := sqlmock.NewRows([]string{"Key"}).AddRow(key)
+
+	s.mock.ExpectQuery(`.*`). // do proper regex labor later as meditative exercise
+					WithArgs(key).
+					WillReturnRows(rows)
+
+	// test function
+	res, err := s.repo.SelectSession(&models.Session{
+		Key: key,
+	})
+
+	require.NoError(s.T(), err)
+	require.Nil(s.T(), deep.Equal(&models.Session{Key: key}, res))
+}
+
+func (s *Suite) TestShouldUpdateSessionByKey() {
+	var (
+		key       = "onekey"
+		data      = []byte("chobanilime")
+		expiresAt = time.Now()
+	)
+
+	// rows := sqlmock.NewRows([]string{"Key"}).AddRow(key)
+
+	s.mock.ExpectBegin()
+	s.mock.ExpectExec(`.*`). // do proper regex labor later as meditative exercise
+					WithArgs(data, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), key).
+					WillReturnResult(sqlmock.NewResult(1, 1))
+	s.mock.ExpectCommit()
+
+	// test function
+	_, err := s.repo.UpdateSession(&models.Session{
+		Key:       key,
+		Data:      data,
+		ExpiresAt: expiresAt,
+	})
+
+	require.NoError(s.T(), err)
+	// require.Nil(s.T(), deep.Equal(&models.Session{Data: data}, res))
+}
+
+func (s *Suite) TestShouldDeleteSession() {
+	var (
+		key = "onekey"
+	)
+
+	// rows := sqlmock.NewRows([]string{"id"}).AddRow("111")
+
+	s.mock.ExpectBegin()
+	s.mock.ExpectExec(`.*`).
+		WithArgs(sqlmock.AnyArg(), key).
+		WillReturnResult(sqlmock.NewResult(1, 1))
+	s.mock.ExpectCommit()
+
+	// test function
+	_, err := s.repo.DeleteSession(&models.Session{
+		Key: key,
+	})
+
+	require.NoError(s.T(), err)
+}

+ 77 - 0
internal/repository/gorm/user.go

@@ -0,0 +1,77 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/crypto/bcrypt"
+	"gorm.io/gorm"
+)
+
+// UserRepository uses gorm.DB for querying the database
+type UserRepository struct {
+	db *gorm.DB
+}
+
+// NewUserRepository returns a DefaultUserRepository which uses
+// gorm.DB for querying the database
+func NewUserRepository(db *gorm.DB) repository.UserRepository {
+	return &UserRepository{db}
+}
+
+// CreateUser adds a new User row to the Users table in the database
+func (repo *UserRepository) CreateUser(user *models.User) (*models.User, error) {
+	if err := repo.db.Create(user).Error; err != nil {
+		return nil, err
+	}
+	return user, nil
+}
+
+// ReadUser finds a single user based on their unique id
+func (repo *UserRepository) ReadUser(id uint) (*models.User, error) {
+	user := &models.User{}
+	if err := repo.db.Where("id = ?", id).First(&user).Error; err != nil {
+		return nil, err
+	}
+	return user, nil
+}
+
+// ReadUserByEmail finds a single user based on their Email. Used primarily for Login.
+func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error) {
+	u := &models.User{}
+	if err := repo.db.Where("email = ?", email).First(&u).Error; err != nil {
+		return nil, err
+	}
+	return u, nil
+}
+
+// UpdateUser modifies an existing User in the database
+func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
+	if err := repo.db.First(&models.User{}, user.ID).Updates(user).Error; err != nil {
+		return nil, err
+	}
+
+	return user, nil
+}
+
+// DeleteUser deletes a single user using their unique id
+func (repo *UserRepository) DeleteUser(user *models.User) (*models.User, error) {
+	if err := repo.db.First(&models.User{}, user.ID).Delete(&user).Error; err != nil {
+		return nil, err
+	}
+	return user, nil
+}
+
+// CheckPassword checks the input password is correct for the provided user id.
+func (repo *UserRepository) CheckPassword(id int, pwd string) (bool, error) {
+	u := &models.User{}
+
+	if err := repo.db.First(u, id).Error; err != nil {
+		return false, err
+	}
+
+	if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(pwd)); err != nil {
+		return false, err
+	}
+
+	return true, nil
+}

+ 7 - 0
internal/repository/repository.go

@@ -0,0 +1,7 @@
+package repository
+
+// Repository collects the repositories for each model
+type Repository struct {
+	User    UserRepository
+	Session SessionRepository
+}

+ 13 - 0
internal/repository/session.go

@@ -0,0 +1,13 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// SessionRepository represents the set of queries on the Session model
+type SessionRepository interface {
+	CreateSession(session *models.Session) (*models.Session, error)
+	UpdateSession(session *models.Session) (*models.Session, error)
+	DeleteSession(session *models.Session) (*models.Session, error)
+	SelectSession(session *models.Session) (*models.Session, error)
+}

+ 13 - 0
internal/repository/test/repository.go

@@ -0,0 +1,13 @@
+package test
+
+import (
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// NewRepository returns a Repository which persists users in memory
+// and accepts a parameter that can trigger read/write errors
+func NewRepository(canQuery bool) *repository.Repository {
+	return &repository.Repository{
+		User: NewUserRepository(canQuery),
+	}
+}

+ 91 - 0
internal/repository/test/user.go

@@ -0,0 +1,91 @@
+package test
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// UserRepository will return errors on queries if canQuery is false
+// and only stores a small set of users in-memory that are indexed by their
+// array index + 1
+type UserRepository struct {
+	canQuery bool
+	users    []*models.User
+}
+
+// NewUserRepository will return errors
+func NewUserRepository(canQuery bool) repository.UserRepository {
+	return &UserRepository{canQuery, []*models.User{}}
+}
+
+// CreateUser adds a new User row to the Users table in array memory
+func (repo *UserRepository) CreateUser(user *models.User) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	fmt.Println(len(repo.users))
+
+	// make sure email doesn't exist
+	for _, u := range repo.users {
+		if u.Email == user.Email {
+			return nil, errors.New("Cannot write database")
+		}
+	}
+
+	users := repo.users
+	users = append(users, user)
+	repo.users = users
+	user.ID = uint(len(repo.users))
+	return user, nil
+}
+
+// ReadUser finds a single user based on their unique id
+func (repo *UserRepository) ReadUser(id uint) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.users) || repo.users[id] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.users[index], nil
+}
+
+// UpdateUser modifies an existing User in the database
+func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(user.ID-1) >= len(repo.users) || repo.users[user.ID] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(user.ID - 1)
+	repo.users[index] = user
+
+	return user, nil
+}
+
+// DeleteUser deletes a single user using their unique id
+func (repo *UserRepository) DeleteUser(user *models.User) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(user.ID-1) >= len(repo.users) || repo.users[user.ID] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(user.ID - 1)
+	repo.users[index] = nil
+
+	return user, nil
+}

+ 18 - 0
internal/repository/user.go

@@ -0,0 +1,18 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// WriteUser is the function type for all User write operations
+type WriteUser func(user *models.User) (*models.User, error)
+
+// UserRepository represents the set of queries on the User model
+type UserRepository interface {
+	CreateUser(user *models.User) (*models.User, error)
+	CheckPassword(id int, pwd string) (bool, error)
+	ReadUser(id uint) (*models.User, error)
+	ReadUserByEmail(email string) (*models.User, error)
+	UpdateUser(user *models.User) (*models.User, error)
+	DeleteUser(user *models.User) (*models.User, error)
+}

+ 8 - 4
server/api/api.go

@@ -4,24 +4,27 @@ import (
 	"github.com/go-playground/locales/en"
 	ut "github.com/go-playground/universal-translator"
 	"github.com/go-playground/validator/v10"
+	sessionstore "github.com/porter-dev/porter/internal/auth"
 	lr "github.com/porter-dev/porter/internal/logger"
-	"gorm.io/gorm"
+	"github.com/porter-dev/porter/internal/repository"
 )
 
 // App represents an API instance with handler methods attached, a DB connection
 // and a logger instance
 type App struct {
 	logger     *lr.Logger
-	db         *gorm.DB
+	repo       *repository.Repository
 	validator  *validator.Validate
+	store      *sessionstore.PGStore
 	translator *ut.Translator
 }
 
 // New returns a new App instance
 func New(
 	logger *lr.Logger,
-	db *gorm.DB,
+	repo *repository.Repository,
 	validator *validator.Validate,
+	store *sessionstore.PGStore,
 ) *App {
 	// for now, will just support the english translator from the
 	// validator/translations package
@@ -31,8 +34,9 @@ func New(
 
 	return &App{
 		logger:     logger,
-		db:         db,
+		repo:       repo,
 		validator:  validator,
+		store:      store,
 		translator: &trans,
 	}
 }

+ 46 - 25
server/api/errors.go

@@ -2,15 +2,17 @@ package api
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
 
 	"github.com/go-playground/validator/v10"
+	"gorm.io/gorm"
 )
 
 const (
 	appErrDataWrite    = "data write error"
+	appErrDataRead     = "data read error"
 	appErrFormDecoding = "could not process JSON body"
+	appErrReadNotFound = "could not find requested object"
 )
 
 // HTTPError is the object returned when the API encounters an error: this
@@ -23,13 +25,6 @@ type HTTPError struct {
 // ErrorCode is a custom Porter error code, useful for frontend messages
 type ErrorCode int64
 
-// Enumeration of API error codes, represented as int64
-const (
-	ErrUserDecode ErrorCode = iota
-	ErrUserValidateFields
-	ErrUserDataWrite
-)
-
 // ------------------------ Error helper functions ------------------------ //
 
 // sendExternalError marshals an HTTPError into JSON: this function will return an error if
@@ -37,11 +32,16 @@ const (
 // client.
 //
 // It then logs it via the app.logger and sends a formatted error to the client.
-func (app *App) sendExternalError(err error, errExt HTTPError, w http.ResponseWriter) (intErr error) {
+func (app *App) sendExternalError(
+	err error,
+	code int,
+	errExt HTTPError,
+	w http.ResponseWriter,
+) (intErr error) {
 	respBytes, newErr := json.Marshal(errExt)
 
 	if newErr != nil {
-		app.handleErrorInternalError(newErr, w)
+		app.handleErrorInternal(newErr, w)
 		return newErr
 	}
 
@@ -51,7 +51,8 @@ func (app *App) sendExternalError(err error, errExt HTTPError, w http.ResponseWr
 		Str("errExt", respBody).
 		Msg("")
 
-	fmt.Fprintf(w, respBody)
+	w.WriteHeader(code)
+	w.Write(respBytes)
 
 	return nil
 }
@@ -65,11 +66,7 @@ func (app *App) handleErrorFormDecoding(err error, code ErrorCode, w http.Respon
 		Errors: []string{appErrFormDecoding},
 	}
 
-	intErr := app.sendExternalError(err, errExt, w)
-
-	if intErr == nil {
-		w.WriteHeader(http.StatusUnprocessableEntity)
-	}
+	app.sendExternalError(err, http.StatusBadRequest, errExt, w)
 }
 
 // handleErrorFormValidation handles an error in the validation of form fields, and
@@ -90,31 +87,55 @@ func (app *App) handleErrorFormValidation(err error, code ErrorCode, w http.Resp
 		Errors: res,
 	}
 
-	intErr := app.sendExternalError(err, errExt, w)
+	app.sendExternalError(err, http.StatusUnprocessableEntity, errExt, w)
+}
 
-	if intErr == nil {
-		w.WriteHeader(http.StatusUnprocessableEntity)
+// handleErrorRead handles an error in reading a record from the DB. If the record is
+// not found, the error message is more descriptive; otherwise, a generic dataRead
+// error is sent.
+func (app *App) handleErrorRead(err error, code ErrorCode, w http.ResponseWriter) {
+	// first check if the error is RecordNotFound -- send a more descriptive
+	// message if that is the case
+	if err == gorm.ErrRecordNotFound {
+		errExt := HTTPError{
+			Code:   code,
+			Errors: []string{appErrReadNotFound},
+		}
+
+		app.sendExternalError(err, http.StatusNotFound, errExt, w)
+
+		return
 	}
+
+	app.handleErrorDataRead(err, code, w)
 }
 
-// handleErrorDataWrite handles a database write error
+// handleErrorDataWrite handles a database write error due to either a connection
+// error with the database or failure to write that wasn't caught by the validators
 func (app *App) handleErrorDataWrite(err error, code ErrorCode, w http.ResponseWriter) {
 	errExt := HTTPError{
 		Code:   code,
 		Errors: []string{appErrDataWrite},
 	}
 
-	intErr := app.sendExternalError(err, errExt, w)
+	app.sendExternalError(err, http.StatusInternalServerError, errExt, w)
+}
 
-	if intErr == nil {
-		w.WriteHeader(http.StatusUnprocessableEntity)
+// handleErrorDataRead handles a database read error due to an internal error, such as
+// the database connection or gorm internals
+func (app *App) handleErrorDataRead(err error, code ErrorCode, w http.ResponseWriter) {
+	errExt := HTTPError{
+		Code:   code,
+		Errors: []string{appErrDataRead},
 	}
+
+	app.sendExternalError(err, http.StatusInternalServerError, errExt, w)
 }
 
 // handleErrorInternalError is a catch-all for internal errors that occur during the
 // processing of a request
-func (app *App) handleErrorInternalError(err error, w http.ResponseWriter) {
+func (app *App) handleErrorInternal(err error, w http.ResponseWriter) {
 	app.logger.Warn().Err(err).Msg("")
 	w.WriteHeader(http.StatusInternalServerError)
-	fmt.Fprintf(w, `{"error": "Internal server error"}`)
+	w.Write([]byte(`{"error": "Internal server error"}`))
 }

+ 137 - 47
server/api/user_handler.go

@@ -3,86 +3,176 @@ package api
 import (
 	"encoding/json"
 	"net/http"
+	"strconv"
 
-	"github.com/porter-dev/porter/internal/queries"
-
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/crypto/bcrypt"
+)
+
+// Enumeration of user API error codes, represented as int64
+const (
+	ErrUserDecode ErrorCode = iota
+	ErrUserValidateFields
+	ErrUserDataWrite
+	ErrUserDataRead
 )
 
 // HandleCreateUser validates a user form entry, converts the user to a gorm
 // model, and saves the user to the database
 func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
-	form := &models.CreateUserForm{}
+	form := &forms.CreateUserForm{}
 
-	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
-		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+	user, err := app.writeUser(form, app.repo.User.CreateUser, w, r)
+
+	if err == nil {
+		app.logger.Info().Msgf("New user created: %d", user.ID)
+		w.WriteHeader(http.StatusCreated)
+	}
+}
+
+// HandleLoginUser checks the request header for cookie and validates the user.
+func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
+	session, _ := app.store.Get(r, "cookie-name")
+
+	// read in email and password from request
+	email := chi.URLParam(r, "email")
+	password := chi.URLParam(r, "password")
+
+	// Authentication goes here
+	// Select User by Username (app.repo.User.ReadUserByUsername) and return storedCreds object that has Password.
+	storedUser, readErr := app.repo.User.ReadUserByEmail(email)
+
+	if readErr != nil {
+		// You're not registered error
+		app.logger.Warn().Err(readErr)
+		w.WriteHeader(http.StatusUnauthorized)
 		return
 	}
 
-	if err := app.validator.Struct(form); err != nil {
-		app.handleErrorFormValidation(err, ErrUserValidateFields, w)
+	if err := bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(password)); err != nil {
+		// If the two passwords don't match, return a 401 status
+		w.WriteHeader(http.StatusUnauthorized)
 		return
 	}
 
-	userModel, err := form.ToUser()
+	// Set user as authenticated
+	session.Values["authenticated"] = true
+	session.Save(r, w)
+}
 
-	if err != nil {
+// HandleReadUser returns an externalized User (models.UserExternal)
+// based on an ID
+func (app *App) HandleReadUser(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+
+	if err != nil || id == 0 {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
 
-	user, err := queries.CreateUser(app.db, userModel)
+	user, err := app.repo.User.ReadUser(uint(id))
 
 	if err != nil {
-		app.handleErrorDataWrite(err, ErrUserDataWrite, w)
+		app.handleErrorRead(err, ErrUserDataRead, w)
 		return
 	}
 
-	app.logger.Info().Msgf("New user created: %d", user.ID)
+	extUser := user.Externalize()
 
-	w.WriteHeader(http.StatusCreated)
-}
+	if err := json.NewEncoder(w).Encode(extUser); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
 
-// HandleReadUser is majestic
-func (app *App) HandleReadUser(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
-	w.Write([]byte("{}"))
 }
 
-// HandleUpdateUser is majestic
+// HandleUpdateUser validates an update user form entry, updates the user
+// in the database, and writes status accepted
 func (app *App) HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
-	w.WriteHeader(http.StatusAccepted)
+	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	form := &forms.UpdateUserForm{
+		ID: uint(id),
+	}
+
+	user, err := app.writeUser(form, app.repo.User.UpdateUser, w, r)
+
+	if err == nil {
+		app.logger.Info().Msgf("User updated: %d", user.ID)
+		w.WriteHeader(http.StatusAccepted)
+	}
 }
 
 // HandleDeleteUser is majestic
 func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
-	w.WriteHeader(http.StatusAccepted)
+	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	// TODO -- HASH AND VERIFY PASSWORD BEFORE USER DELETION
+	form := &forms.DeleteUserForm{
+		ID:       uint(id),
+		Password: "testing",
+	}
+
+	user, err := app.writeUser(form, app.repo.User.DeleteUser, w, r)
+
+	if err == nil {
+		app.logger.Info().Msgf("User deleted: %d", user.ID)
+		w.WriteHeader(http.StatusAccepted)
+	}
 }
 
-// GenerateUser creates a new user based on a unique ID and a kubeconfig
-// func GenerateUser(id string, kubeconfig []byte) *User {
-// 	conf := kubernetes.KubeConfig{}
-
-// 	err := yaml.Unmarshal(kubeconfig, &conf)
-
-// 	// TODO -- HANDLE ERROR
-// 	if err != nil {
-// 		fmt.Println("ERROR IN UNMARSHALING")
-// 	}
-
-// 	// generate the user's clusters
-// 	clusters := conf.ToClusterConfigs()
-
-// 	return &User{
-// 		ID:            id,
-// 		Clusters:      clusters,
-// 		RawKubeConfig: kubeconfig,
-// 	}
-// }
-
-// // printUser is a helper function to print a user's config without sensitive information
-// func (u *User) printUser() {
-// 	for _, cluster := range u.Clusters {
-// 		fmt.Println(cluster.Name, cluster.Context, cluster.Server, cluster.User)
-// 	}
-// }
+// ------------------------ User handler helper functions ------------------------ //
+
+// writeUser will take a POST or PUT request to the /api/users endpoint and decode
+// the request into a forms.WriteUserForm model, convert it to a models.User, and
+// write to the database.
+func (app *App) writeUser(
+	form forms.WriteUserForm,
+	dbWrite repository.WriteUser,
+	w http.ResponseWriter,
+	r *http.Request,
+) (*models.User, error) {
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return nil, err
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrUserValidateFields, w)
+		return nil, err
+	}
+
+	// convert the form to a user model -- WriteUserForm must implement ToUser
+	userModel, err := form.ToUser()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return nil, err
+	}
+
+	// handle write to the database
+	user, err := dbWrite(userModel)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, ErrUserDataWrite, w)
+		return nil, err
+	}
+
+	return user, nil
+}

+ 157 - 0
server/api/user_handler_test.go

@@ -0,0 +1,157 @@
+package api_test
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/test"
+	"github.com/porter-dev/porter/server/api"
+	"github.com/porter-dev/porter/server/requestlog"
+
+	lr "github.com/porter-dev/porter/internal/logger"
+	vr "github.com/porter-dev/porter/internal/validator"
+)
+
+func initApi(canQuery bool) (*api.App, *repository.Repository) {
+	appConf := config.Conf{
+		Debug: true,
+		Server: config.ServerConf{
+			Port:         8080,
+			TimeoutRead:  time.Second * 5,
+			TimeoutWrite: time.Second * 10,
+			TimeoutIdle:  time.Second * 15,
+		},
+		// unimportant
+		Db: config.DBConf{},
+	}
+
+	logger := lr.NewConsole(appConf.Debug)
+	validator := vr.New()
+
+	repo := test.NewRepository(canQuery)
+
+	return api.New(logger, repo, validator), repo
+}
+
+type userTest struct {
+	init func(repo *repository.Repository)
+	msg,
+	method,
+	endpoint,
+	body string
+	expStatus int
+	expBody   string
+	canQuery  bool
+}
+
+var createUserTests = []userTest{
+	// userTest{
+	// 	msg:      "Create user",
+	// 	method:   "POST",
+	// 	endpoint: "/api/users",
+	// 	body: `{
+	// 		"email": "belanger@getporter.dev",
+	// 		"password": "hello"
+	// 	}`,
+	// 	expStatus: http.StatusCreated,
+	// 	expBody:   "",
+	// 	canQuery:  true,
+	// },
+	// userTest{
+	// 	msg:      "Create user invalid email",
+	// 	method:   "POST",
+	// 	endpoint: "/api/users",
+	// 	body: `{
+	// 		"email": "notanemail",
+	// 		"password": "hello"
+	// 	}`,
+	// 	expStatus: http.StatusUnprocessableEntity,
+	// 	expBody:   `{"code":1,"errors":["email validation failed"]}`,
+	// 	canQuery:  true,
+	// },
+	// userTest{
+	// 	msg:      "Create user missing field",
+	// 	method:   "POST",
+	// 	endpoint: "/api/users",
+	// 	body: `{
+	// 		"password": "hello"
+	// 	}`,
+	// 	expStatus: http.StatusUnprocessableEntity,
+	// 	expBody:   `{"code":1,"errors":["required validation failed"]}`,
+	// 	canQuery:  true,
+	// },
+	// userTest{
+	// 	msg:      "Create user cannot write to db",
+	// 	method:   "POST",
+	// 	endpoint: "/api/users",
+	// 	body: `{
+	// 		"email": "belanger@getporter.dev",
+	// 		"password": "hello"
+	// 	}`,
+	// 	expStatus: http.StatusInternalServerError,
+	// 	expBody:   `{"code":2,"errors":["data write error"]}`,
+	// 	canQuery:  false,
+	// },
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:      "Create user same email",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"email": "belanger@getporter.dev",
+			"password": "hello"
+		}`,
+		expStatus: http.StatusInternalServerError,
+		expBody:   "",
+		canQuery:  true,
+	},
+}
+
+func TestHandleCreateUser(t *testing.T) {
+	for _, c := range createUserTests {
+		// create a mock API
+		api, repo := initApi(c.canQuery)
+
+		if c.init != nil {
+			c.init(repo)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		rr := httptest.NewRecorder()
+		handler := requestlog.NewHandler(api.HandleCreateUser, api.Logger())
+
+		handler.ServeHTTP(rr, req)
+
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		if body := rr.Body.String(); body != c.expBody {
+			t.Errorf("%s, handler returned wrong body: got %v want %v",
+				c.msg, body, c.expBody)
+		}
+	}
+}
+
+// var readUserTests = []userTest

+ 37 - 0
server/router/middleware/auth.go

@@ -0,0 +1,37 @@
+package middleware
+
+import (
+	"net/http"
+
+	"github.com/gorilla/sessions"
+)
+
+var (
+	key   = []byte("secret")             // change to os.Getenv("SESSION_KEY")
+	store = sessions.NewCookieStore(key) // Swap out with custom store
+)
+
+// Authenticate is middleware for authentication
+func Authenticate(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if isLoggedIn(r) {
+			next.ServeHTTP(w, r)
+		} else {
+			http.Error(w, http.StatusText(403), 403)
+			return
+		}
+
+		return
+	})
+}
+
+// Helpers
+
+func isLoggedIn(r *http.Request) bool {
+	session, _ := store.Get(r, "session-id")
+
+	if auth, ok := session.Values["authenticated"].(bool); !auth || !ok {
+		return false
+	}
+	return true
+}

+ 4 - 1
server/router/router.go

@@ -17,7 +17,10 @@ func New(a *api.App) *chi.Mux {
 
 		// /api/users routes
 		r.Method("POST", "/users", requestlog.NewHandler(a.HandleCreateUser, l))
-		r.Method("GET", "/users", requestlog.NewHandler(a.HandleReadUser, l))
+		r.Method("PUT", "/users/{id}", requestlog.NewHandler(a.HandleUpdateUser, l))
+		r.Method("GET", "/users/{id}", requestlog.NewHandler(a.HandleReadUser, l))
+		r.Method("DELETE", "/users/{id}", requestlog.NewHandler(a.HandleDeleteUser, l))
+		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
 	})
 
 	return r