diff --git a/.env_example b/.env_example index bf1b569..3759284 100644 --- a/.env_example +++ b/.env_example @@ -1,3 +1,4 @@ ADMIN_API_KEY=changeMe DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/pwrpln" +JWT_SHARED_SECRET=changeMe WEB_BIND_HOST=":4000" \ No newline at end of file diff --git a/README.md b/README.md index 3e7b37f..ad9708c 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,8 @@ Example of setting up a postgres SQL server: ## Admin account -On first startup with a clean database, an account with name "admin" is created with no password, using the API Key from ADMIN_API_KEY in the .env file. \ No newline at end of file +On first startup with a clean database, an account with name "admin" and the field "role" with a value "admin" is created with no password, using the API Key from ADMIN_API_KEY in the .env file. + +## Special account field: "role" + +The account field "role" is a bit special, in that if it contains "admin" as one of its values, that grants access to all methods on all accounts on this service. It might be a good idea to use the field "role" for authorization throughout your services. diff --git a/db/migrations/20201207191913_first.sql b/db/migrations/20201207191913_first.sql index 68bfe6c..b5b7648 100644 --- a/db/migrations/20201207191913_first.sql +++ b/db/migrations/20201207191913_first.sql @@ -3,11 +3,11 @@ CREATE TABLE "accounts" ( "id" uuid PRIMARY KEY, "created" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - "accountName" text NOT NULL, + "name" text NOT NULL, "apiKey" text, "password" text ); -CREATE UNIQUE INDEX idx_accountname ON accounts ("accountName"); +CREATE UNIQUE INDEX idx_accountname ON accounts ("name"); CREATE TABLE "accountsFields" ( "id" uuid PRIMARY KEY, @@ -20,4 +20,15 @@ ALTER TABLE "accountsFields" ADD FOREIGN KEY ("accountId") REFERENCES "accounts" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT; CREATE UNIQUE INDEX idx_accountsfields ON "accountsFields" ("accountId", "name"); +CREATE TABLE "renewalTokens" ( + "accountId" uuid NOT NULL, + "exp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP + '24 hours', + "token" char(60) NOT NULL +); +ALTER TABLE "renewalTokens" + ADD FOREIGN KEY ("accountId") REFERENCES "accounts" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT; +CREATE INDEX idx_renewaltokensaccountid ON "renewalTokens" ("accountId"); +CREATE INDEX idx_renewaltokensexp ON "renewalTokens" ("exp"); +CREATE INDEX idx_renewaltokenstoken ON "renewalTokens" ("token"); + -- migrate:down \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index 415a3ac..21dbf5c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -20,7 +20,7 @@ SET default_table_access_method = heap; CREATE TABLE public.accounts ( id uuid NOT NULL, created timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - "accountName" text NOT NULL, + name text NOT NULL, "apiKey" text, password text ); @@ -39,6 +39,17 @@ CREATE TABLE public."accountsFields" ( ); +-- +-- Name: renewalTokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."renewalTokens" ( + "accountId" uuid NOT NULL, + exp timestamp without time zone DEFAULT (CURRENT_TIMESTAMP + '24:00:00'::interval) NOT NULL, + token character(60) NOT NULL +); + + -- -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: - -- @@ -76,7 +87,7 @@ ALTER TABLE ONLY public.schema_migrations -- Name: idx_accountname; Type: INDEX; Schema: public; Owner: - -- -CREATE UNIQUE INDEX idx_accountname ON public.accounts USING btree ("accountName"); +CREATE UNIQUE INDEX idx_accountname ON public.accounts USING btree (name); -- @@ -86,6 +97,27 @@ CREATE UNIQUE INDEX idx_accountname ON public.accounts USING btree ("accountName CREATE UNIQUE INDEX idx_accountsfields ON public."accountsFields" USING btree ("accountId", name); +-- +-- Name: idx_renewaltokensaccountid; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_renewaltokensaccountid ON public."renewalTokens" USING btree ("accountId"); + + +-- +-- Name: idx_renewaltokensexp; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_renewaltokensexp ON public."renewalTokens" USING btree (exp); + + +-- +-- Name: idx_renewaltokenstoken; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_renewaltokenstoken ON public."renewalTokens" USING btree (token); + + -- -- Name: accountsFields accountsFields_accountId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -94,6 +126,14 @@ ALTER TABLE ONLY public."accountsFields" ADD CONSTRAINT "accountsFields_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES public.accounts(id) ON UPDATE RESTRICT ON DELETE RESTRICT; +-- +-- Name: renewalTokens renewalTokens_accountId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public."renewalTokens" + ADD CONSTRAINT "renewalTokens_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES public.accounts(id) ON UPDATE RESTRICT ON DELETE RESTRICT; + + -- -- PostgreSQL database dump complete -- diff --git a/go.mod b/go.mod index 3b629f4..d6a6638 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gitlab.larvit.se/power-plan/auth go 1.15 require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/gofiber/fiber/v2 v2.3.2 github.com/google/uuid v1.1.2 github.com/jackc/pgx/v4 v4.10.1 diff --git a/go.sum b/go.sum index 312e101..d15449d 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,9 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gofiber/fiber/v2 v2.3.2 h1:8ecrfzlfTUsboMybK6TQIfPoObmPR1hEoKU7Ni1pElg= github.com/gofiber/fiber/v2 v2.3.2/go.mod h1:f8BRRIMjMdRyt2qmJ/0Sea3j3rwwfufPrh9WNBRiVZ0= diff --git a/src/db/accounts.go b/src/db/accounts.go index f947863..ef643fe 100644 --- a/src/db/accounts.go +++ b/src/db/accounts.go @@ -6,16 +6,17 @@ import ( "github.com/google/uuid" log "github.com/sirupsen/logrus" + "gitlab.larvit.se/power-plan/auth/src/utils" ) // AccountCreate writes a user to database func (d Db) AccountCreate(input AccountCreateInput) (CreatedAccount, error) { - accountSQL := "INSERT INTO accounts (id, \"accountName\", \"apiKey\", password) VALUES($1,$2,$3,$4);" + accountSQL := "INSERT INTO accounts (id, name, \"apiKey\", password) VALUES($1,$2,$3,$4);" - _, err := d.DbPool.Exec(context.Background(), accountSQL, input.ID, input.AccountName, input.APIKey, input.Password) + _, err := d.DbPool.Exec(context.Background(), accountSQL, input.ID, input.Name, input.APIKey, input.Password) if err != nil { if strings.HasPrefix(err.Error(), "ERROR: duplicate key") { - log.WithFields(log.Fields{"accountName": input.AccountName}).Debug("Duplicate accountName in accounts database") + log.WithFields(log.Fields{"name": input.Name}).Debug("Duplicate name in accounts database") } else { log.Error("Database error when trying to add account: " + err.Error()) } @@ -24,8 +25,8 @@ func (d Db) AccountCreate(input AccountCreateInput) (CreatedAccount, error) { } log.WithFields(log.Fields{ - "id": input.ID, - "accountName": input.AccountName, + "id": input.ID, + "name": input.Name, }).Info("Added account to database") accountFieldsSQL := "INSERT INTO \"accountsFields\" (id, \"accountId\", name, value) VALUES($1,$2,$3,$4);" @@ -50,8 +51,79 @@ func (d Db) AccountCreate(input AccountCreateInput) (CreatedAccount, error) { } return CreatedAccount{ - ID: input.ID, - AccountName: input.AccountName, - APIKey: input.APIKey, + ID: input.ID, + Name: input.Name, + APIKey: input.APIKey, }, nil } + +// AccountGet fetches an account from the database +func (d Db) AccountGet(accountID string, APIKey string) (Account, error) { + logContext := log.WithFields(log.Fields{ + "accountID": accountID, + "APIKey": len(APIKey), + }) + + logContext.Debug("Trying to get account") + + var account Account + var searchParam string + accountSQL := "SELECT id, created, name, \"password\" FROM accounts WHERE " + if accountID != "" { + accountSQL = accountSQL + "id = $1" + searchParam = accountID + } else if APIKey != "" { + accountSQL = accountSQL + "\"apiKey\" = $1" + searchParam = APIKey + } + + accountErr := d.DbPool.QueryRow(context.Background(), accountSQL, searchParam).Scan(&account.ID, &account.Created, &account.Name, &account.Password) + if accountErr != nil { + if accountErr.Error() == "no rows in result set" { + logContext.Debug("No account found") + return Account{}, accountErr + } + + logContext.Error("Database error when fetching account, err: " + accountErr.Error()) + return Account{}, accountErr + } + + fieldsSQL := "SELECT name, value FROM \"accountsFields\" WHERE \"accountId\" = $1" + rows, fieldsErr := d.DbPool.Query(context.Background(), fieldsSQL, account.ID) + if fieldsErr != nil { + logContext.Error("Database error when fetching account fields, err: " + accountErr.Error()) + return Account{}, fieldsErr + } + + account.Fields = make(map[string][]string) + for rows.Next() { + var name string + var value []string + err := rows.Scan(&name, &value) + if err != nil { + logContext.Error("Could not get name or value from database row, err: " + err.Error()) + return Account{}, err + } + account.Fields[name] = value + } + + return account, nil +} + +// RenewalTokenGet obtain a new renewal token +func (d Db) RenewalTokenGet(accountID string) (string, error) { + logContext := log.WithFields(log.Fields{"accountID": accountID}) + + logContext.Debug("Createing new renewal token") + + newToken := utils.RandString(60) + + insertSQL := "INSERT INTO \"renewalTokens\" (\"accountId\",token) VALUES($1,$2);" + _, insertErr := d.DbPool.Exec(context.Background(), insertSQL, accountID, newToken) + if insertErr != nil { + logContext.Error("Could not insert into database table \"renewalTokens\", err: " + insertErr.Error()) + return "", insertErr + } + + return newToken, nil +} diff --git a/src/db/types.go b/src/db/types.go index d838a4c..848f095 100644 --- a/src/db/types.go +++ b/src/db/types.go @@ -1,15 +1,26 @@ package db import ( + "time" + "github.com/google/uuid" "github.com/jackc/pgx/v4/pgxpool" ) +// Account is an account as represented in the database +type Account struct { + ID uuid.UUID `json:"id"` + Created time.Time `json:"created"` + Fields map[string][]string `json:"fields"` + Name string `json:"name"` + Password string `json:"-"` +} + // CreatedAccount is a newly created account in the system type CreatedAccount struct { - ID uuid.UUID `json:"id"` - AccountName string `json:"accountName"` - APIKey string `json:"apiKey"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + APIKey string `json:"apiKey"` } // AccountCreateInputFields yes @@ -20,11 +31,11 @@ type AccountCreateInputFields struct { // AccountCreateInput is used as input struct for database creation of account type AccountCreateInput struct { - ID uuid.UUID - AccountName string - APIKey string - Fields []AccountCreateInputFields - Password string + ID uuid.UUID + Name string + APIKey string + Fields []AccountCreateInputFields + Password string } // Db struct diff --git a/src/handlers/helpers.go b/src/handlers/helpers.go new file mode 100644 index 0000000..7dc5d98 --- /dev/null +++ b/src/handlers/helpers.go @@ -0,0 +1,115 @@ +package handlers + +import ( + "errors" + "strings" + "time" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/gofiber/fiber/v2" + log "github.com/sirupsen/logrus" + "gitlab.larvit.se/power-plan/auth/src/db" +) + +func (h Handlers) returnTokens(account db.Account, c *fiber.Ctx) error { + expirationTime := time.Now().Add(15 * time.Minute) + + claims := &Claims{ + AccountID: account.ID.String(), + AccountName: account.Name, + AccountFields: account.Fields, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expirationTime.Unix(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(h.JwtKey) + if err != nil { + log.Error("Could not create token string, err: " + err.Error()) + return c.Status(500).JSON([]ResJSONError{{Error: "Could not create JWT token string"}}) + } + + renewalToken, renewalTokenErr := h.Db.RenewalTokenGet(account.ID.String()) + if renewalTokenErr != nil { + log.Error("Could not create renewal token, err: " + renewalTokenErr.Error()) + return c.Status(500).JSON([]ResJSONError{{Error: "Could not create renewal token"}}) + } + + return c.Status(200).JSON(ResToken{ + JWT: tokenString, + RenewalToken: renewalToken, + }) +} + +func (h Handlers) parseJWT(JWT string) (Claims, error) { + logContext := log.WithFields(log.Fields{"JWT": JWT}) + logContext.Trace("Parsing JWT") + + JWT = strings.TrimPrefix(JWT, "bearer ") // Since the Authorization header should always start with "bearer " + logContext.WithFields(log.Fields{"TrimmedJWT": JWT}).Trace("JWT trimmed") + + claims := &Claims{} + + token, err := jwt.ParseWithClaims(JWT, claims, func(token *jwt.Token) (interface{}, error) { + return h.JwtKey, nil + }) + if err != nil { + return Claims{}, err + } + if !token.Valid { + err := errors.New("Invalid token") + return Claims{}, err + } + + return *claims, nil +} + +func (h Handlers) parseHeaders(c *fiber.Ctx) map[string]string { + headersMap := make(map[string]string) + + headersString := c.Request().Header.String() + headersLines := strings.Split(headersString, "\r\n") + + for _, line := range headersLines { + lineParts := strings.Split(line, ": ") + + if len(lineParts) == 1 { + log.WithFields(log.Fields{"line": line}).Trace("Ignoring header line") + } else { + headersMap[lineParts[0]] = lineParts[1] + } + } + + return headersMap +} + +// RequireAdminRole returns nil if no error is found +func (h Handlers) RequireAdminRole(c *fiber.Ctx) error { + headers := h.parseHeaders(c) + + if headers["Authorization"] == "" { + return errors.New("Authorization header is missing") + } + + claims, claimsErr := h.parseJWT(headers["Authorization"]) + if claimsErr != nil { + return claimsErr + } + + if claims.AccountFields == nil { + return errors.New("Account have no fields at all") + } + + if claims.AccountFields["role"] == nil { + return errors.New("Account have no field named \"role\"") + } + + for _, role := range claims.AccountFields["role"] { + if role == "admin" { + return nil + } + } + + return errors.New("No \"admin\" role found on account") +} diff --git a/src/handlers/middlewares.go b/src/handlers/middlewares.go index 48c72ba..0091c3c 100644 --- a/src/handlers/middlewares.go +++ b/src/handlers/middlewares.go @@ -5,6 +5,17 @@ import ( log "github.com/sirupsen/logrus" ) +// Log all requests +func (h Handlers) Log(c *fiber.Ctx) error { + log.WithFields(log.Fields{ + "method": c.Method(), + "url": c.OriginalURL(), + }).Debug("http request") + + c.Next() + return nil +} + // RequireJSON is a middleware that makes sure the request content-type always is application/json (or nothing, defaulting to application/json) func (h Handlers) RequireJSON(c *fiber.Ctx) error { c.Accepts("application/json") diff --git a/src/handlers/post.go b/src/handlers/post.go index ce157eb..0768cbd 100644 --- a/src/handlers/post.go +++ b/src/handlers/post.go @@ -13,10 +13,15 @@ import ( // AccountCreate creates a new account func (h Handlers) AccountCreate(c *fiber.Ctx) error { + authErr := h.RequireAdminRole(c) + if authErr != nil { + return c.Status(403).JSON([]ResJSONError{{Error: authErr.Error()}}) + } + type AccountInput struct { - AccountName string `json:"accountName"` - Password string `json:"password"` - Fields []db.AccountCreateInputFields `json:"fields"` + Name string `json:"name"` + Password string `json:"password"` + Fields []db.AccountCreateInputFields `json:"fields"` } accountInput := new(AccountInput) @@ -29,8 +34,8 @@ func (h Handlers) AccountCreate(c *fiber.Ctx) error { var errors []ResJSONError - if accountInput.AccountName == "" { - errors = append(errors, ResJSONError{Error: "Can not be empty", Field: "accountName"}) + if accountInput.Name == "" { + errors = append(errors, ResJSONError{Error: "Can not be empty", Field: "name"}) } if len(errors) != 0 { @@ -47,14 +52,12 @@ func (h Handlers) AccountCreate(c *fiber.Ctx) error { log.Fatal("Could not hash password, err: " + pwdErr.Error()) } - apiKey := utils.RandString(60) - createdAccount, err := h.Db.AccountCreate(db.AccountCreateInput{ - ID: newAccountID, - AccountName: accountInput.AccountName, - APIKey: apiKey, - Fields: accountInput.Fields, - Password: hashedPwd, + ID: newAccountID, + Name: accountInput.Name, + APIKey: utils.RandString(60), + Fields: accountInput.Fields, + Password: hashedPwd, }) if err != nil { @@ -69,5 +72,17 @@ func (h Handlers) AccountCreate(c *fiber.Ctx) error { // AccountAuthAPIKey auths an APIKey func (h Handlers) AccountAuthAPIKey(c *fiber.Ctx) error { - return c.Status(200).JSON("key höhö") + inputAPIKey := string(c.Request().Body()) + inputAPIKey = inputAPIKey[1 : len(inputAPIKey)-1] + + resolvedAccount, accountErr := h.Db.AccountGet("", inputAPIKey) + if accountErr != nil { + if accountErr.Error() == "no rows in result set" { + return c.Status(403).JSON([]ResJSONError{{Error: "Invalid credentials"}}) + } + log.Error("Something went wrong when trying to fetch account") + return c.Status(500).JSON([]ResJSONError{{Error: "Something went wrong when trying to fetch account"}}) + } + + return h.returnTokens(resolvedAccount, c) } diff --git a/src/handlers/types.go b/src/handlers/types.go index 67e3c66..f3839fb 100644 --- a/src/handlers/types.go +++ b/src/handlers/types.go @@ -1,12 +1,22 @@ package handlers import ( + jwt "github.com/dgrijalva/jwt-go" "gitlab.larvit.se/power-plan/auth/src/db" ) +// Claims is the JWT struct +type Claims struct { + AccountID string `json:"accountId"` + AccountFields map[string][]string `json:"accountFields"` + AccountName string `json:"accountName"` + jwt.StandardClaims +} + // Handlers is the overall struct for all http request handlers type Handlers struct { - Db db.Db + Db db.Db + JwtKey []byte } // ResJSONError is an error field that is used in JSON error responses @@ -14,3 +24,9 @@ type ResJSONError struct { Error string `json:"error"` Field string `json:"field,omitempty"` } + +// ResToken is a response used to return a valid token and valid renewalToken +type ResToken struct { + JWT string `json:"jwt"` + RenewalToken string `json:"renewalToken"` +} diff --git a/src/main.go b/src/main.go index 01e7ecd..efe6cf1 100644 --- a/src/main.go +++ b/src/main.go @@ -20,10 +20,11 @@ func createAdminAccount(Db db.Db) { log.Fatal("Could not create new Uuid, err: " + uuidErr.Error()) } _, adminAccountErr := Db.AccountCreate(db.AccountCreateInput{ - ID: adminAccountID, - AccountName: "admin", - APIKey: os.Getenv("ADMIN_API_KEY"), - Password: "", + ID: adminAccountID, + Name: "admin", + APIKey: os.Getenv("ADMIN_API_KEY"), + Password: "", + Fields: []db.AccountCreateInputFields{{Name: "role", Values: []string{"admin"}}}, }) if adminAccountErr != nil && strings.HasPrefix(adminAccountErr.Error(), "ERROR: duplicate key") { log.Info("Admin account already created, nothing written to database") @@ -42,6 +43,14 @@ func main() { // log.SetReportCaller(true) log.SetLevel(log.DebugLevel) + if os.Getenv("JWT_SHARED_SECRET") == "changeMe" { + log.Fatal("You must change JWT_SHARED_SECRET in .env") + } + if os.Getenv("ADMIN_API_KEY") == "changeMe" { + log.Fatal("You must change ADMIN_API_KEY in .env") + } + jwtKey := []byte(os.Getenv("JWT_SHARED_SECRET")) + dbPool, err := pgxpool.Connect(context.Background(), os.Getenv("DATABASE_URL")) if err != nil { log.Fatal("Failed to open DB connection: ", err) @@ -53,10 +62,13 @@ func main() { app := fiber.New() Db := db.Db{DbPool: dbPool} - handlers := h.Handlers{Db: Db} + handlers := h.Handlers{Db: Db, JwtKey: jwtKey} createAdminAccount(Db) + // Log all requests + app.Use(handlers.Log) + // Always require application/json app.Use(handlers.RequireJSON)