diff --git a/.env.example b/.env.example index 07dd44b..eed85f0 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,19 @@ TOKEN_HOUR_LIFESPAN=24 # Secret key to sign tokens (openssl rand -hex 32) API_SECRET=some-random-string -READ_API_AUTHENTICATION_ENABLED=false \ No newline at end of file +READ_API_AUTHENTICATION_ENABLED=false + +PORT=8080 + +# OIDC Provider +# The URL for retrieving keys for Token Parsing +JWKS_URI=https://provider/keys + +# The field in ID Token that is to be used as username +OIDC_USERNAME_KEY=display_name + +# The field in ID Token that is to be used as email +OIDC_EMAIL_KEY=mail + +# The issuer url +OIDC_ISSUER=https://provider \ No newline at end of file diff --git a/.github/workflows/api-swagger.yml b/.github/workflows/api-swagger.yml index a963b81..5e796aa 100644 --- a/.github/workflows/api-swagger.yml +++ b/.github/workflows/api-swagger.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.20' + go-version: '1.21' check-latest: true cache: true @@ -54,7 +54,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.20' + go-version: '1.21' check-latest: true cache: true diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 03dc834..966aedb 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.20' + go-version: '1.21' check-latest: true cache: true diff --git a/.github/workflows/golangci.yml b/.github/workflows/golangci.yml index 0b05719..3855dbd 100644 --- a/.github/workflows/golangci.yml +++ b/.github/workflows/golangci.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.20' + go-version: '1.21' check-latest: true cache: true diff --git a/Dockerfile b/Dockerfile index d7675ba..4744099 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2024 Kaushlendra Pratap # SPDX-License-Identifier: GPL-2.0-only -FROM golang:1.20 AS build +FROM golang:1.21 AS build WORKDIR /LicenseDb diff --git a/cmd/laas/docs/docs.go b/cmd/laas/docs/docs.go index bfc9a7a..3681f01 100644 --- a/cmd/laas/docs/docs.go +++ b/cmd/laas/docs/docs.go @@ -790,6 +790,12 @@ const docTemplate = `{ } } } + }, + "409": { + "description": "User registered only with OIDC authentication", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } } } } @@ -1859,6 +1865,12 @@ const docTemplate = `{ "summary": "Get users", "operationId": "GetAllUsers", "parameters": [ + { + "type": "boolean", + "description": "Active user only", + "name": "active", + "in": "query" + }, { "type": "integer", "description": "Page number", @@ -1912,7 +1924,54 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.UserInput" + "$ref": "#/definitions/models.UserCreate" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.UserResponse" + } + }, + "400": { + "description": "Invalid json body", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } + }, + "409": { + "description": "User already exists", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } + } + } + } + }, + "/users/oidc": { + "post": { + "description": "Create a new service user via oidc", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Create new user via oidc", + "operationId": "CreateOidcUser", + "parameters": [ + { + "description": "User to create", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.OidcUserCreate" } } ], @@ -1938,14 +1997,14 @@ const docTemplate = `{ } } }, - "/users/{id}": { + "/users/{username}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a single user by ID", + "description": "Get a single user by username", "consumes": [ "application/json" ], @@ -1959,9 +2018,9 @@ const docTemplate = `{ "operationId": "GetUser", "parameters": [ { - "type": "integer", - "description": "User ID", - "name": "id", + "type": "string", + "description": "Username", + "name": "username", "in": "path", "required": true } @@ -1986,6 +2045,102 @@ const docTemplate = `{ } } } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deactivate an user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Deactivate user", + "operationId": "DeleteUser", + "parameters": [ + { + "type": "string", + "description": "Username of the user to be marked as inactive", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "No user with given username found", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update a service user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update user", + "operationId": "UpdateUser", + "parameters": [ + { + "type": "string", + "description": "username of the user to be updated", + "name": "username", + "in": "path", + "required": true + }, + { + "description": "User to update", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UserUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.UserResponse" + } + }, + "400": { + "description": "Invalid json body", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } + }, + "403": { + "description": "This resource requires elevated access rights", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } + } + } } } }, @@ -2756,6 +2911,14 @@ const docTemplate = `{ } } }, + "models.OidcUserCreate": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, "models.PaginationMeta": { "type": "object", "properties": { @@ -2829,18 +2992,18 @@ const docTemplate = `{ }, "models.User": { "type": "object", - "required": [ - "userlevel", - "username" - ], "properties": { "id": { "type": "integer", "example": 123 }, - "userlevel": { + "user_email": { "type": "string", - "example": "admin" + "example": "fossy@org.com" + }, + "user_level": { + "type": "string", + "example": "USER" }, "username": { "type": "string", @@ -2848,21 +3011,30 @@ const docTemplate = `{ } } }, - "models.UserInput": { + "models.UserCreate": { "type": "object", "required": [ - "password", - "userlevel", + "user_email", + "user_level", + "user_password", "username" ], "properties": { - "password": { + "user_email": { "type": "string", - "example": "fossy" + "example": "fossy@org.com" }, - "userlevel": { + "user_level": { "type": "string", - "example": "admin" + "enum": [ + "USER", + "ADMIN" + ], + "example": "ADMIN" + }, + "user_password": { + "type": "string", + "example": "fossy" }, "username": { "type": "string", @@ -2904,6 +3076,29 @@ const docTemplate = `{ "example": 200 } } + }, + "models.UserUpdate": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "user_level": { + "type": "string", + "enum": [ + "USER", + "ADMIN" + ], + "example": "ADMIN" + }, + "user_password": { + "type": "string" + }, + "username": { + "type": "string", + "example": "fossy" + } + } } }, "securityDefinitions": { diff --git a/cmd/laas/docs/swagger.json b/cmd/laas/docs/swagger.json index 34ddbe6..9c9d7ba 100644 --- a/cmd/laas/docs/swagger.json +++ b/cmd/laas/docs/swagger.json @@ -783,6 +783,12 @@ } } } + }, + "409": { + "description": "User registered only with OIDC authentication", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } } } } @@ -1852,6 +1858,12 @@ "summary": "Get users", "operationId": "GetAllUsers", "parameters": [ + { + "type": "boolean", + "description": "Active user only", + "name": "active", + "in": "query" + }, { "type": "integer", "description": "Page number", @@ -1905,7 +1917,54 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.UserInput" + "$ref": "#/definitions/models.UserCreate" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.UserResponse" + } + }, + "400": { + "description": "Invalid json body", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } + }, + "409": { + "description": "User already exists", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } + } + } + } + }, + "/users/oidc": { + "post": { + "description": "Create a new service user via oidc", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Create new user via oidc", + "operationId": "CreateOidcUser", + "parameters": [ + { + "description": "User to create", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.OidcUserCreate" } } ], @@ -1931,14 +1990,14 @@ } } }, - "/users/{id}": { + "/users/{username}": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get a single user by ID", + "description": "Get a single user by username", "consumes": [ "application/json" ], @@ -1952,9 +2011,9 @@ "operationId": "GetUser", "parameters": [ { - "type": "integer", - "description": "User ID", - "name": "id", + "type": "string", + "description": "Username", + "name": "username", "in": "path", "required": true } @@ -1979,6 +2038,102 @@ } } } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deactivate an user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Deactivate user", + "operationId": "DeleteUser", + "parameters": [ + { + "type": "string", + "description": "Username of the user to be marked as inactive", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "No user with given username found", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update a service user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update user", + "operationId": "UpdateUser", + "parameters": [ + { + "type": "string", + "description": "username of the user to be updated", + "name": "username", + "in": "path", + "required": true + }, + { + "description": "User to update", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UserUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.UserResponse" + } + }, + "400": { + "description": "Invalid json body", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } + }, + "403": { + "description": "This resource requires elevated access rights", + "schema": { + "$ref": "#/definitions/models.LicenseError" + } + } + } } } }, @@ -2749,6 +2904,14 @@ } } }, + "models.OidcUserCreate": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, "models.PaginationMeta": { "type": "object", "properties": { @@ -2822,18 +2985,18 @@ }, "models.User": { "type": "object", - "required": [ - "userlevel", - "username" - ], "properties": { "id": { "type": "integer", "example": 123 }, - "userlevel": { + "user_email": { "type": "string", - "example": "admin" + "example": "fossy@org.com" + }, + "user_level": { + "type": "string", + "example": "USER" }, "username": { "type": "string", @@ -2841,21 +3004,30 @@ } } }, - "models.UserInput": { + "models.UserCreate": { "type": "object", "required": [ - "password", - "userlevel", + "user_email", + "user_level", + "user_password", "username" ], "properties": { - "password": { + "user_email": { "type": "string", - "example": "fossy" + "example": "fossy@org.com" }, - "userlevel": { + "user_level": { "type": "string", - "example": "admin" + "enum": [ + "USER", + "ADMIN" + ], + "example": "ADMIN" + }, + "user_password": { + "type": "string", + "example": "fossy" }, "username": { "type": "string", @@ -2897,6 +3069,29 @@ "example": 200 } } + }, + "models.UserUpdate": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "user_level": { + "type": "string", + "enum": [ + "USER", + "ADMIN" + ], + "example": "ADMIN" + }, + "user_password": { + "type": "string" + }, + "username": { + "type": "string", + "example": "fossy" + } + } } }, "securityDefinitions": { diff --git a/cmd/laas/docs/swagger.yaml b/cmd/laas/docs/swagger.yaml index 358b74b..019d2ae 100644 --- a/cmd/laas/docs/swagger.yaml +++ b/cmd/laas/docs/swagger.yaml @@ -538,6 +538,11 @@ definitions: example: RISK type: string type: object + models.OidcUserCreate: + properties: + token: + type: string + type: object models.PaginationMeta: properties: limit: @@ -593,30 +598,37 @@ definitions: id: example: 123 type: integer - userlevel: - example: admin + user_email: + example: fossy@org.com + type: string + user_level: + example: USER type: string username: example: fossy type: string - required: - - userlevel - - username type: object - models.UserInput: + models.UserCreate: properties: - password: - example: fossy + user_email: + example: fossy@org.com + type: string + user_level: + enum: + - USER + - ADMIN + example: ADMIN type: string - userlevel: - example: admin + user_password: + example: fossy type: string username: example: fossy type: string required: - - password - - userlevel + - user_email + - user_level + - user_password - username type: object models.UserLogin: @@ -643,6 +655,22 @@ definitions: example: 200 type: integer type: object + models.UserUpdate: + properties: + active: + type: boolean + user_level: + enum: + - USER + - ADMIN + example: ADMIN + type: string + user_password: + type: string + username: + example: fossy + type: string + type: object info: contact: email: fossology@fossology.org @@ -1153,6 +1181,10 @@ paths: token: type: string type: object + "409": + description: User registered only with OIDC authentication + schema: + $ref: '#/definitions/models.LicenseError' summary: Login tags: - Users @@ -1838,6 +1870,10 @@ paths: description: Get all service users operationId: GetAllUsers parameters: + - description: Active user only + in: query + name: active + type: boolean - description: Page number in: query name: page @@ -1873,7 +1909,7 @@ paths: name: user required: true schema: - $ref: '#/definitions/models.UserInput' + $ref: '#/definitions/models.UserCreate' produces: - application/json responses: @@ -1894,18 +1930,43 @@ paths: summary: Create new user tags: - Users - /users/{id}: + /users/{username}: + delete: + consumes: + - application/json + description: Deactivate an user + operationId: DeleteUser + parameters: + - description: Username of the user to be marked as inactive + in: path + name: username + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "404": + description: No user with given username found + schema: + $ref: '#/definitions/models.LicenseError' + security: + - ApiKeyAuth: [] + summary: Deactivate user + tags: + - Users get: consumes: - application/json - description: Get a single user by ID + description: Get a single user by username operationId: GetUser parameters: - - description: User ID + - description: Username in: path - name: id + name: username required: true - type: integer + type: string produces: - application/json responses: @@ -1926,6 +1987,74 @@ paths: summary: Get a user tags: - Users + patch: + consumes: + - application/json + description: Update a service user + operationId: UpdateUser + parameters: + - description: username of the user to be updated + in: path + name: username + required: true + type: string + - description: User to update + in: body + name: user + required: true + schema: + $ref: '#/definitions/models.UserUpdate' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.UserResponse' + "400": + description: Invalid json body + schema: + $ref: '#/definitions/models.LicenseError' + "403": + description: This resource requires elevated access rights + schema: + $ref: '#/definitions/models.LicenseError' + security: + - ApiKeyAuth: [] + summary: Update user + tags: + - Users + /users/oidc: + post: + consumes: + - application/json + description: Create a new service user via oidc + operationId: CreateOidcUser + parameters: + - description: User to create + in: body + name: user + required: true + schema: + $ref: '#/definitions/models.OidcUserCreate' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.UserResponse' + "400": + description: Invalid json body + schema: + $ref: '#/definitions/models.LicenseError' + "409": + description: User already exists + schema: + $ref: '#/definitions/models.LicenseError' + summary: Create new user via oidc + tags: + - Users securityDefinitions: ApiKeyAuth: description: Token from /login endpoint diff --git a/cmd/laas/main.go b/cmd/laas/main.go index 7080ed9..63e3540 100644 --- a/cmd/laas/main.go +++ b/cmd/laas/main.go @@ -9,13 +9,16 @@ package main import ( "flag" "log" + "os" + "github.com/MicahParks/keyfunc/v3" "github.com/joho/godotenv" "gorm.io/gorm/clause" _ "github.com/dave/jennifer/jen" _ "github.com/fossology/LicenseDb/cmd/laas/docs" "github.com/fossology/LicenseDb/pkg/api" + "github.com/fossology/LicenseDb/pkg/auth" "github.com/fossology/LicenseDb/pkg/db" "github.com/fossology/LicenseDb/pkg/models" "github.com/fossology/LicenseDb/pkg/utils" @@ -48,6 +51,11 @@ func main() { flag.Parse() + auth.Jwks, err = keyfunc.NewDefault([]string{os.Getenv("JWKS_URI")}) + if err != nil { + log.Fatalf("Failed to create a keyfunc.Keyfunc from the oidc provider's URL: %s", err) + } + db.Connect(dbhost, port, user, dbname, password) if err := db.DB.AutoMigrate(&models.LicenseDB{}); err != nil { diff --git a/go.mod b/go.mod index 1f188c6..b126e99 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module github.com/fossology/LicenseDb -go 1.20 +go 1.21 + +toolchain go1.21.6 require ( github.com/gin-gonic/gin v1.9.1 - github.com/golang-jwt/jwt/v4 v4.5.1 github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.9.0 github.com/swaggo/files v1.0.1 @@ -17,14 +18,17 @@ require ( ) require ( + github.com/MicahParks/jwkset v0.5.19 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect golang.org/x/sync v0.5.0 // indirect + golang.org/x/time v0.5.0 // indirect gorm.io/driver/mysql v1.4.7 // indirect ) require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/MicahParks/keyfunc/v3 v3.3.5 github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/bytedance/sonic v1.9.1 // indirect @@ -41,6 +45,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.22.0 github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.4 // indirect diff --git a/go.sum b/go.sum index 741b9f1..bcc1d33 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/MicahParks/jwkset v0.5.19 h1:XZCsgJv05DBCvxEHYEHlSafqiuVn5ESG0VRB331Fxhw= +github.com/MicahParks/jwkset v0.5.19/go.mod h1:q8ptTGn/Z9c4MwbcfeCDssADeVQb3Pk7PnVxrvi+2QY= +github.com/MicahParks/keyfunc/v3 v3.3.5 h1:7ceAJLUAldnoueHDNzF8Bx06oVcQ5CfJnYwNt1U3YYo= +github.com/MicahParks/keyfunc/v3 v3.3.5/go.mod h1:SdCCyMJn/bYqWDvARspC6nCT8Sk74MjuAY22C7dCST8= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= @@ -19,6 +23,7 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= @@ -34,6 +39,7 @@ github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -44,11 +50,14 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= @@ -74,6 +83,7 @@ github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZX github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -87,7 +97,9 @@ github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= +github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -134,6 +146,7 @@ golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uy golang.org/x/exp v0.0.0-20231219180239-dc181d75b848/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= @@ -166,6 +179,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -178,6 +193,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= @@ -192,7 +208,9 @@ gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8o gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= +gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0= +gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64= gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= diff --git a/pkg/api/api.go b/pkg/api/api.go index 36e3224..4761a0b 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -97,6 +97,10 @@ func Router() *gin.Engine { { apiCollection.GET("", GetAPICollection) } + oidc := unAuthorizedv1.Group("/users/oidc") + { + oidc.POST("", auth.CreateOidcUser) + } } authorizedv1 := r.Group("/api/v1") @@ -110,7 +114,7 @@ func Router() *gin.Engine { licenses.GET("/preview", GetAllLicensePreviews) licenses.POST("", CreateLicense) licenses.PATCH(":shortname", UpdateLicense) - licenses.POST("import", ImportLicenses) + licenses.POST("import", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), ImportLicenses) } search := authorizedv1.Group("/search") { @@ -118,9 +122,11 @@ func Router() *gin.Engine { } users := authorizedv1.Group("/users") { - users.GET("", auth.GetAllUser) - users.GET(":id", auth.GetUser) - users.POST("", auth.CreateUser) + users.GET("", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), auth.GetAllUser) + users.GET(":username", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), auth.GetUser) + users.POST("", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), auth.CreateUser) + users.PATCH(":username", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), auth.UpdateUser) + users.DELETE(":username", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), auth.DeleteUser) } obligations := authorizedv1.Group("/obligations") { @@ -130,15 +136,15 @@ func Router() *gin.Engine { obligations.GET(":topic/audits", GetObligationAudits) obligations.GET("export", ExportObligations) obligations.POST("", CreateObligation) - obligations.POST("import", ImportObligations) + obligations.POST("import", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), ImportObligations) obligations.PATCH(":topic", UpdateObligation) obligations.DELETE(":topic", DeleteObligation) - obligations.GET("/types", GetAllObligationType) - obligations.POST("/types", CreateObligationType) - obligations.DELETE("/types/:type", DeleteObligationType) - obligations.GET("/classifications", GetAllObligationClassification) - obligations.POST("/classifications", CreateObligationClassification) - obligations.DELETE("/classifications/:classification", DeleteObligationClassification) + obligations.GET("/types", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), GetAllObligationType) + obligations.POST("/types", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), CreateObligationType) + obligations.DELETE("/types/:type", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), DeleteObligationType) + obligations.GET("/classifications", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), GetAllObligationClassification) + obligations.POST("/classifications", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), CreateObligationClassification) + obligations.DELETE("/classifications/:classification", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), DeleteObligationClassification) } obMap := authorizedv1.Group("/obligation_maps") { @@ -197,6 +203,10 @@ func Router() *gin.Engine { { login.POST("", auth.Login) } + oidc := unAuthorizedv1.Group("/users/oidc") + { + oidc.POST("", auth.CreateOidcUser) + } apiCollection := unAuthorizedv1.Group("/apiCollection") { apiCollection.GET("", GetAPICollection) @@ -214,22 +224,24 @@ func Router() *gin.Engine { } users := authorizedv1.Group("/users") { - users.GET("", auth.GetAllUser) - users.GET(":id", auth.GetUser) - users.POST("", auth.CreateUser) + users.GET("", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), auth.GetAllUser) + users.GET(":username", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), auth.GetUser) + users.POST("", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), auth.CreateUser) + users.PATCH(":username", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), auth.UpdateUser) + users.DELETE(":username", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), auth.DeleteUser) } obligations := authorizedv1.Group("/obligations") { obligations.POST("", CreateObligation) - obligations.POST("import", ImportObligations) + obligations.POST("import", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), ImportObligations) obligations.PATCH(":topic", UpdateObligation) obligations.DELETE(":topic", DeleteObligation) - obligations.GET("/types", GetAllObligationType) - obligations.POST("/types", CreateObligationType) - obligations.DELETE("/types/:type", DeleteObligationType) - obligations.GET("/classifications", GetAllObligationClassification) - obligations.POST("/classifications", CreateObligationClassification) - obligations.DELETE("/classifications/:classification", DeleteObligationClassification) + obligations.GET("/types", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), GetAllObligationType) + obligations.POST("/types", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), CreateObligationType) + obligations.DELETE("/types/:type", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), DeleteObligationType) + obligations.GET("/classifications", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), GetAllObligationClassification) + obligations.POST("/classifications", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), CreateObligationClassification) + obligations.DELETE("/classifications/:classification", middleware.RoleBasedAccessMiddleware([]string{"ADMIN"}), DeleteObligationClassification) } obMap := authorizedv1.Group("/obligation_maps") { diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index ce4766a..e9c147d 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -205,12 +205,14 @@ func TestSearchInLicense2(t *testing.T) { func TestGetUser(t *testing.T) { password := "fossy" + username := "fossy" + userlevel := "ADMIN" expectUser := models.User{ - Username: "fossy", + Username: &username, Userpassword: &password, - Userlevel: "admin", + Userlevel: &userlevel, } - w := makeRequest("GET", "/api/user/1", nil, false) + w := makeRequest("GET", "/api/user/fossy", nil, false) assert.Equal(t, http.StatusOK, w.Code) var res models.UserResponse @@ -225,10 +227,12 @@ func TestGetUser(t *testing.T) { func TestCreateUser(t *testing.T) { password := "abc123" + username := "fossy" + userlevel := "ADMIN" user := models.User{ - Username: "general_user", + Username: &username, Userpassword: &password, - Userlevel: "participant", + Userlevel: &userlevel, } w := makeRequest("POST", "/api/user", user, true) assert.Equal(t, http.StatusOK, w.Code) diff --git a/pkg/api/licenses.go b/pkg/api/licenses.go index 8260528..15abbe3 100644 --- a/pkg/api/licenses.go +++ b/pkg/api/licenses.go @@ -718,7 +718,7 @@ func addChangelogsForLicenseUpdate(tx *gorm.DB, username string, if len(changes) != 0 { var user models.User - if err := tx.Where(models.User{Username: username}).First(&user).Error; err != nil { + if err := tx.Where(models.User{Username: &username}).First(&user).Error; err != nil { return err } diff --git a/pkg/api/obligationClassifications.go b/pkg/api/obligationClassifications.go index 689eaeb..9a71000 100644 --- a/pkg/api/obligationClassifications.go +++ b/pkg/api/obligationClassifications.go @@ -256,7 +256,7 @@ func toggleObligationClassificationActiveStatus(c *gin.Context, tx *gorm.DB, obC username := c.GetString("username") var user models.User - if err := tx.Where(models.User{Username: username}).First(&user).Error; err != nil { + if err := tx.Where(models.User{Username: &username}).First(&user).Error; err != nil { return errors.New("unable to change 'active' status of obligation classification") } diff --git a/pkg/api/obligationTypes.go b/pkg/api/obligationTypes.go index df90612..b6e6a1d 100644 --- a/pkg/api/obligationTypes.go +++ b/pkg/api/obligationTypes.go @@ -256,7 +256,7 @@ func toggleObligationTypeActiveStatus(c *gin.Context, tx *gorm.DB, obType *model username := c.GetString("username") var user models.User - if err := tx.Where(models.User{Username: username}).First(&user).Error; err != nil { + if err := tx.Where(models.User{Username: &username}).First(&user).Error; err != nil { return errors.New("unable to change 'active' status of obligation type") } diff --git a/pkg/api/obligations.go b/pkg/api/obligations.go index f55017d..99ded14 100644 --- a/pkg/api/obligations.go +++ b/pkg/api/obligations.go @@ -618,7 +618,7 @@ func ExportObligations(c *gin.Context) { func addChangelogsForObligationUpdate(tx *gorm.DB, username string, newObligation, oldObligation *models.Obligation) error { var user models.User - if err := tx.Where(models.User{Username: username}).First(&user).Error; err != nil { + if err := tx.Where(models.User{Username: &username}).First(&user).Error; err != nil { return err } var changes []models.ChangeLog diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 0ca831d..50803c3 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -7,14 +7,22 @@ package auth import ( + "errors" "fmt" + "html" + "log" "net/http" "os" "strconv" + "strings" "time" - "github.com/golang-jwt/jwt/v4" + "github.com/MicahParks/keyfunc/v3" + "github.com/go-playground/validator/v10" + "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/gin-gonic/gin" @@ -23,6 +31,8 @@ import ( "github.com/fossology/LicenseDb/pkg/utils" ) +var Jwks keyfunc.Keyfunc + // CreateUser creates a new user // // @Summary Create new user @@ -31,14 +41,14 @@ import ( // @Tags Users // @Accept json // @Produce json -// @Param user body models.UserInput true "User to create" +// @Param user body models.UserCreate true "User to create" // @Success 201 {object} models.UserResponse // @Failure 400 {object} models.LicenseError "Invalid json body" // @Failure 409 {object} models.LicenseError "User already exists" // @Security ApiKeyAuth // @Router /users [post] func CreateUser(c *gin.Context) { - var input models.UserInput + var input models.UserCreate if err := c.ShouldBindJSON(&input); err != nil { er := models.LicenseError{ Status: http.StatusBadRequest, @@ -51,12 +61,21 @@ func CreateUser(c *gin.Context) { return } - user := models.User{ - Username: input.Username, - Userlevel: input.Userlevel, - Userpassword: input.Userpassword, + validate := validator.New(validator.WithRequiredStructEnabled()) + if err := validate.Struct(&input); err != nil { + er := models.LicenseError{ + Status: http.StatusBadRequest, + Message: "can not create user with these field values", + Error: fmt.Sprintf("field '%s' failed validation: %s\n", err.(validator.ValidationErrors)[0].Field(), err.(validator.ValidationErrors)[0].Tag()), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusBadRequest, er) + return } + user := models.User(input) + *user.Username = html.EscapeString(strings.TrimSpace(*user.Username)) err := utils.HashPassword(&user) if err != nil { er := models.LicenseError{ @@ -72,20 +91,199 @@ func CreateUser(c *gin.Context) { result := db.DB.Where(models.User{Username: user.Username}).FirstOrCreate(&user) if result.Error != nil { + errMessage := "Something went wrong. Try again." + if errors.Is(result.Error, gorm.ErrDuplicatedKey) { + errMessage = "User with this email id already exists" + } er := models.LicenseError{ - Status: http.StatusInternalServerError, + Status: http.StatusConflict, Message: "Failed to create the new user", - Error: result.Error.Error(), + Error: errMessage, Path: c.Request.URL.Path, Timestamp: time.Now().Format(time.RFC3339), } - c.JSON(http.StatusInternalServerError, er) + c.JSON(http.StatusConflict, er) return } else if result.RowsAffected == 0 { + errMessage := fmt.Sprintf("Error: User with username '%s' already exists", *user.Username) + if !*user.Active { + errMessage = fmt.Sprintf("Error: User with username '%s' already exists, but is deactivated", *user.Username) + } + er := models.LicenseError{ + Status: http.StatusConflict, + Message: "can not create user", + Error: errMessage, + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusConflict, er) + return + } + + res := models.UserResponse{ + Data: []models.User{user}, + Status: http.StatusCreated, + Meta: &models.PaginationMeta{ + ResourceCount: 1, + }, + } + + c.JSON(http.StatusCreated, res) +} + +// CreateOidcUser creates a new user via oidc +// +// @Summary Create new user via oidc +// @Description Create a new service user via oidc +// @Id CreateOidcUser +// @Tags Users +// @Accept json +// @Produce json +// @Param user body models.OidcUserCreate true "User to create" +// @Success 201 {object} models.UserResponse +// @Failure 400 {object} models.LicenseError "Invalid json body" +// @Failure 409 {object} models.LicenseError "User already exists" +// @Router /users/oidc [post] +func CreateOidcUser(c *gin.Context) { + var tokenBody models.OidcUserCreate + if err := c.ShouldBindJSON(&tokenBody); err != nil { + er := models.LicenseError{ + Status: http.StatusBadRequest, + Message: "invalid json body", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusBadRequest, er) + return + } + + parsedToken, err := jwt.Parse(tokenBody.Token, Jwks.Keyfunc) + if err != nil || !parsedToken.Valid { + er := models.LicenseError{ + Status: http.StatusInternalServerError, + Message: "Something went wrong", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusInternalServerError, er) + return + } + + claims, ok := parsedToken.Claims.(jwt.MapClaims) + if !ok { + er := models.LicenseError{ + Status: http.StatusInternalServerError, + Message: "Something went wrong", + Error: "Please check your credentials and try again", + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusInternalServerError, er) + return + } + + sub := claims["sub"].(string) + iss := claims["iss"].(string) + + if os.Getenv("OIDC_ISSUER") == "" || iss != os.Getenv("OIDC_ISSUER") { + er := models.LicenseError{ + Status: http.StatusUnauthorized, + Message: "Please check your credentials and try again", + Error: "Please check your credentials and try again", + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusUnauthorized, er) + log.Printf("\033[31mError: Issuer '%s' not supported\033[0m", iss) + return + } + + email := claims[os.Getenv("OIDC_EMAIL_KEY")].(string) + username := claims[os.Getenv("OIDC_USERNAME_KEY")].(string) + level := "USER" + + var user, updatedUser models.User + user = models.User{ + Username: &username, + UserEmail: &email, + Userlevel: &level, + OidcSub: &sub, + OidcIss: &iss, + } + + result := db.DB. + Where(&models.User{Username: user.Username}). + FirstOrCreate(&user) + + if result.Error != nil { + errMessage := "Something went wrong. Try again." + if errors.Is(result.Error, gorm.ErrDuplicatedKey) { + if err := db.DB.Where(&models.User{UserEmail: &email}).First(&user).Error; err != nil { + er := models.LicenseError{ + Status: http.StatusInternalServerError, + Message: "Something went wrong", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusInternalServerError, er) + return + } + if user.OidcSub == nil { + // Case when user is logged in with email password and wants to log in with OIDC with the same email + // TODO: Add changelogs for user + updatedUser = models.User{ + Id: user.Id, + OidcSub: &sub, + OidcIss: &iss, + } + updatedUser.Id = user.Id + if err := db.DB.Clauses(clause.Returning{}).Updates(&updatedUser).Error; err != nil { + er := models.LicenseError{ + Status: http.StatusInternalServerError, + Message: "Failed to login", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusInternalServerError, er) + return + } + res := models.UserResponse{ + Data: []models.User{updatedUser}, + Status: http.StatusOK, + Meta: &models.PaginationMeta{ + ResourceCount: 1, + }, + } + c.JSON(http.StatusOK, res) + return + } else { + errMessage = "User with this email id already exists" + } + } er := models.LicenseError{ Status: http.StatusConflict, - Message: "can not create user with same username", - Error: fmt.Sprintf("Error: User with username '%s' already exists", user.Username), + Message: "Failed to create the new user", + Error: errMessage, + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusConflict, er) + return + } + + if result.RowsAffected == 0 { + errMessage := fmt.Sprintf("Error: User with username '%s' already exists", *user.Username) + if !*user.Active { + errMessage = fmt.Sprintf("Error: User with username '%s' already exists, but is deactivated", *user.Username) + } + er := models.LicenseError{ + Status: http.StatusConflict, + Message: "Failed to create user", + Error: errMessage, Path: c.Request.URL.Path, Timestamp: time.Now().Format(time.RFC3339), } @@ -104,6 +302,148 @@ func CreateUser(c *gin.Context) { c.JSON(http.StatusCreated, res) } +// UpdateUser updates a user +// +// @Summary Update user +// @Description Update a service user +// @Id UpdateUser +// @Tags Users +// @Accept json +// @Produce json +// @Param username path string true "username of the user to be updated" +// @Param user body models.UserUpdate true "User to update" +// @Success 200 {object} models.UserResponse +// @Failure 400 {object} models.LicenseError "Invalid json body" +// @Failure 403 {object} models.LicenseError "This resource requires elevated access rights" +// @Security ApiKeyAuth +// @Router /users/{username} [patch] +func UpdateUser(c *gin.Context) { + var user models.User + username := c.Param("username") + + if err := db.DB.Where(models.User{Username: &username}).First(&user).Error; err != nil { + er := models.LicenseError{ + Status: http.StatusNotFound, + Message: "no user with such username exists", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusNotFound, er) + return + } + + var input models.UserUpdate + if err := c.ShouldBindJSON(&input); err != nil { + er := models.LicenseError{ + Status: http.StatusBadRequest, + Message: "invalid json body", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusBadRequest, er) + return + } + + validate := validator.New(validator.WithRequiredStructEnabled()) + if err := validate.Struct(&input); err != nil { + er := models.LicenseError{ + Status: http.StatusBadRequest, + Message: "can not create user with these field values", + Error: fmt.Sprintf("field '%s' failed validation: %s\n", err.(validator.ValidationErrors)[0].Field(), err.(validator.ValidationErrors)[0].Tag()), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusBadRequest, er) + return + } + + updatedUser := models.User(input) + if updatedUser.Username != nil { + *updatedUser.Username = html.EscapeString(strings.TrimSpace(*updatedUser.Username)) + } + if updatedUser.Userpassword != nil { + err := utils.HashPassword(&updatedUser) + if err != nil { + er := models.LicenseError{ + Status: http.StatusBadRequest, + Message: "password hashing failed", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusBadRequest, er) + return + } + } + + updatedUser.Id = user.Id + if err := db.DB.Clauses(clause.Returning{}).Updates(&updatedUser).Error; err != nil { + er := models.LicenseError{ + Status: http.StatusInternalServerError, + Message: "Failed to update user", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusInternalServerError, er) + return + } + + res := models.UserResponse{ + Data: []models.User{updatedUser}, + Status: http.StatusOK, + Meta: &models.PaginationMeta{ + ResourceCount: 1, + }, + } + c.JSON(http.StatusOK, res) +} + +// DeleteUser marks an existing user record as inactive +// +// @Summary Deactivate user +// @Description Deactivate an user +// @Id DeleteUser +// @Tags Users +// @Accept json +// @Produce json +// @Param username path string true "Username of the user to be marked as inactive" +// @Success 204 +// @Failure 404 {object} models.LicenseError "No user with given username found" +// @Security ApiKeyAuth +// @Router /users/{username} [delete] +func DeleteUser(c *gin.Context) { + var user models.User + username := c.Param("username") + active := true + if err := db.DB.Where(models.User{Username: &username, Active: &active}).First(&user).Error; err != nil { + er := models.LicenseError{ + Status: http.StatusNotFound, + Message: "no user with such username exists", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusNotFound, er) + return + } + *user.Active = false + if err := db.DB.Updates(&user).Error; err != nil { + er := models.LicenseError{ + Status: http.StatusInternalServerError, + Message: "failed to delete user", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusNotFound, er) + return + } + c.Status(http.StatusNoContent) +} + // GetAllUser retrieves a list of all users from the database. // // @Summary Get users @@ -112,19 +452,23 @@ func CreateUser(c *gin.Context) { // @Tags Users // @Accept json // @Produce json -// @Param page query int false "Page number" -// @Param limit query int false "Number of records per page" +// @Param active query bool false "Active user only" +// @Param page query int false "Page number" +// @Param limit query int false "Number of records per page" // @Success 200 {object} models.UserResponse // @Failure 404 {object} models.LicenseError "Users not found" // @Security ApiKeyAuth // @Router /users [get] func GetAllUser(c *gin.Context) { - var users []models.User + active, err := strconv.ParseBool(c.Query("active")) + if err != nil { + active = false + } + var users []models.User query := db.DB.Model(&models.User{}) _ = utils.PreparePaginateResponse(c, query, &models.UserResponse{}) - - if err := query.Find(&users).Error; err != nil { + if err := query.Where(&models.User{Active: &active}).Find(&users).Error; err != nil { er := models.LicenseError{ Status: http.StatusNotFound, Message: "Users not found", @@ -152,29 +496,26 @@ func GetAllUser(c *gin.Context) { // GetUser retrieves a user by their user ID from the database. // // @Summary Get a user -// @Description Get a single user by ID +// @Description Get a single user by username // @Id GetUser // @Tags Users // @Accept json // @Produce json -// @Param id path int true "User ID" -// @Success 200 {object} models.UserResponse -// @Failure 400 {object} models.LicenseError "Invalid user id" -// @Failure 404 {object} models.LicenseError "User not found" +// @Param username path string true "Username" +// @Success 200 {object} models.UserResponse +// @Failure 400 {object} models.LicenseError "Invalid user id" +// @Failure 404 {object} models.LicenseError "User not found" // @Security ApiKeyAuth -// @Router /users/{id} [get] +// @Router /users/{username} [get] func GetUser(c *gin.Context) { var user models.User - id := c.Param("id") - parsedId, err := utils.ParseIdToInt(c, id, "user") - if err != nil { - return - } + username := c.Param("username") - if err := db.DB.Where(models.User{Id: parsedId}).First(&user).Error; err != nil { + active := true + if err := db.DB.Where(models.User{Username: &username, Active: &active}).First(&user).Error; err != nil { er := models.LicenseError{ Status: http.StatusNotFound, - Message: "no user with such user id exists", + Message: "no user with such username exists", Error: err.Error(), Path: c.Request.URL.Path, Timestamp: time.Now().Format(time.RFC3339), @@ -182,7 +523,7 @@ func GetUser(c *gin.Context) { c.JSON(http.StatusNotFound, er) return } - user.Userpassword = nil + res := models.UserResponse{ Data: []models.User{user}, Status: http.StatusOK, @@ -204,6 +545,7 @@ func GetUser(c *gin.Context) { // @Produce json // @Param user body models.UserLogin true "Login credentials" // @Success 200 {object} object{token=string} "JWT token" +// @Failure 409 {object} models.LicenseError "User registered only with OIDC authentication" // @Router /login [post] func Login(c *gin.Context) { var input models.UserLogin @@ -221,9 +563,9 @@ func Login(c *gin.Context) { username := input.Username password := input.Userpassword - + active := true var user models.User - result := db.DB.Where(models.User{Username: username}).First(&user) + result := db.DB.Where(models.User{Username: &username, Active: &active}).First(&user) if result.Error != nil { er := models.LicenseError{ Status: http.StatusUnauthorized, @@ -234,7 +576,19 @@ func Login(c *gin.Context) { } c.JSON(http.StatusUnauthorized, er) - c.Abort() + return + } + + if user.Userpassword == nil { + er := models.LicenseError{ + Status: http.StatusConflict, + Message: "user registered only with OIDC authentication", + Error: "user registered only with OIDC authentication", + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + + c.JSON(http.StatusConflict, er) return } @@ -249,7 +603,6 @@ func Login(c *gin.Context) { } c.JSON(http.StatusInternalServerError, er) - c.Abort() return } @@ -265,7 +618,6 @@ func Login(c *gin.Context) { } c.JSON(http.StatusUnauthorized, er) - c.Abort() return } diff --git a/pkg/db/db.go b/pkg/db/db.go index 892ac69..d2ac959 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -18,7 +18,7 @@ var DB *gorm.DB func Connect(dbhost, port, user, dbname, password *string) { dburi := fmt.Sprintf("host=%s port=%s user=%s dbname=%s password=%s", *dbhost, *port, *user, *dbname, *password) - gormConfig := &gorm.Config{} + gormConfig := &gorm.Config{TranslateError: true} database, err := gorm.Open(postgres.Open(dburi), gormConfig) if err != nil { log.Fatalf("Failed to connect to database: %v", err) diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 5943d58..4d382a9 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -17,11 +17,12 @@ import ( "strconv" "time" + "github.com/fossology/LicenseDb/pkg/auth" "github.com/fossology/LicenseDb/pkg/db" "github.com/fossology/LicenseDb/pkg/models" "github.com/fossology/LicenseDb/pkg/utils" "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) // AuthenticationMiddleware is a middleware function for user authentication. @@ -43,18 +44,12 @@ func AuthenticationMiddleware() gin.HandlerFunc { return } - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(os.Getenv("API_SECRET")), nil - }) - + unverfiedParsedToken, _, err := jwt.NewParser().ParseUnverified(tokenString, &jwt.RegisteredClaims{}) if err != nil { er := models.LicenseError{ Status: http.StatusUnauthorized, Message: "Please check your credentials and try again", - Error: err.Error(), + Error: "wrong credentials were passed", Path: c.Request.URL.Path, Timestamp: time.Now().Format(time.RFC3339), } @@ -64,12 +59,12 @@ func AuthenticationMiddleware() gin.HandlerFunc { return } - claims, ok := token.Claims.(jwt.MapClaims) - if !ok || !token.Valid { + unverifiedClaims, ok := unverfiedParsedToken.Claims.(*jwt.RegisteredClaims) + if !ok { er := models.LicenseError{ Status: http.StatusUnauthorized, - Message: "Invalid token", - Error: "Invalid token", + Message: "Please check your credentials and try again", + Error: "wrong credentials were passed", Path: c.Request.URL.Path, Timestamp: time.Now().Format(time.RFC3339), } @@ -79,24 +74,153 @@ func AuthenticationMiddleware() gin.HandlerFunc { return } - userId := int64(claims["user"].(map[string]interface{})["id"].(float64)) + if unverifiedClaims.Issuer != "" { + if os.Getenv("OIDC_ISSUER") == "" || unverifiedClaims.Issuer != os.Getenv("OIDC_ISSUER") { + er := models.LicenseError{ + Status: http.StatusUnauthorized, + Message: "Please check your credentials and try again", + Error: "Please check your credentials and try again", + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusUnauthorized, er) + log.Printf("\033[31mError: Issuer '%s' not supported\033[0m", unverifiedClaims.Issuer) + c.Abort() + return + } + + parsedToken, err := jwt.Parse(tokenString, auth.Jwks.Keyfunc) + if err != nil || !parsedToken.Valid { + er := models.LicenseError{ + Status: http.StatusUnauthorized, + Message: "Please check your credentials and try again", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusUnauthorized, er) + c.Abort() + return + } + + claims, ok := parsedToken.Claims.(jwt.MapClaims) + if !ok { + er := models.LicenseError{ + Status: http.StatusUnauthorized, + Message: "Please check your credentials and try again", + Error: "wrong credentials were passed", + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusUnauthorized, er) + c.Abort() + return + } + + sub := claims["sub"].(string) + iss := claims["iss"].(string) + + var user models.User + if err := db.DB.Where(models.User{OidcSub: &sub, OidcIss: &iss}).First(&user).Error; err != nil { + er := models.LicenseError{ + Status: http.StatusUnauthorized, + Message: "User not found", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + + c.JSON(http.StatusUnauthorized, er) + c.Abort() + return + } + + c.Set("username", *user.Username) + c.Set("role", *user.Userlevel) + } else { + parsedToken, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(os.Getenv("API_SECRET")), nil + }) + + if err != nil { + er := models.LicenseError{ + Status: http.StatusUnauthorized, + Message: "Please check your credentials and try again", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + + c.JSON(http.StatusUnauthorized, er) + c.Abort() + return + } - var user models.User - if err := db.DB.Where(models.User{Id: userId}).First(&user).Error; err != nil { + claims, ok := parsedToken.Claims.(jwt.MapClaims) + if !ok { + er := models.LicenseError{ + Status: http.StatusUnauthorized, + Message: "Invalid token", + Error: "Invalid token", + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + + c.JSON(http.StatusUnauthorized, er) + c.Abort() + return + } + + userId := int64(claims["user"].(map[string]interface{})["id"].(float64)) + + var user models.User + if err := db.DB.Where(models.User{Id: userId}).First(&user).Error; err != nil { + er := models.LicenseError{ + Status: http.StatusUnauthorized, + Message: "User not found", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + + c.JSON(http.StatusUnauthorized, er) + c.Abort() + return + } + c.Set("username", *user.Username) + c.Set("role", *user.Userlevel) + } + c.Next() + } +} + +// RoleBasedAccessMiddleware is a middleware function for giving role based access to apis. +func RoleBasedAccessMiddleware(roles []string) gin.HandlerFunc { + return func(c *gin.Context) { + role := c.GetString("role") + found := false + for _, r := range roles { + if role == r { + found = true + break + } + } + if !found { er := models.LicenseError{ - Status: http.StatusUnauthorized, - Message: "User not found", - Error: err.Error(), + Status: http.StatusForbidden, + Message: "this resource requires elevated access rights", + Error: "this resource requires elevated access rights", Path: c.Request.URL.Path, Timestamp: time.Now().Format(time.RFC3339), } - c.JSON(http.StatusUnauthorized, er) + c.JSON(http.StatusForbidden, er) c.Abort() return } - - c.Set("username", user.Username) c.Next() } } diff --git a/pkg/models/types.go b/pkg/models/types.go index a15a3b8..6b10abe 100644 --- a/pkg/models/types.go +++ b/pkg/models/types.go @@ -212,15 +212,39 @@ type LicenseError struct { // User struct is representation of user information. type User struct { Id int64 `json:"id" gorm:"primary_key" example:"123"` - Username string `json:"username" gorm:"unique;not null" binding:"required" example:"fossy"` - Userlevel string `json:"userlevel" binding:"required" example:"admin"` + Username *string `json:"username" gorm:"unique;not null" example:"fossy"` + UserEmail *string `json:"user_email" gorm:"unique;not null" example:"fossy@org.com"` + Userlevel *string `json:"user_level" example:"USER"` Userpassword *string `json:"-"` -} - -type UserInput struct { - Username string `json:"username" gorm:"unique;not null" binding:"required" example:"fossy"` - Userlevel string `json:"userlevel" binding:"required" example:"admin"` - Userpassword *string `json:"password,omitempty" binding:"required" example:"fossy"` + Active *bool `json:"-" gorm:"not null;default:true"` + OidcSub *string `json:"-" gorm:"unique"` + OidcIss *string `json:"-"` +} + +type UserCreate struct { + Id int64 `json:"-"` + Username *string `json:"username" validate:"required" example:"fossy"` + UserEmail *string `json:"user_email" validate:"required,email" example:"fossy@org.com"` + Userlevel *string `json:"user_level" validate:"required,oneof=USER ADMIN" example:"ADMIN"` + Userpassword *string `json:"user_password" validate:"required" example:"fossy"` + Active *bool `json:"-"` + OidcSub *string `json:"-"` + OidcIss *string `json:"-"` +} + +type UserUpdate struct { + Id int64 `json:"-"` + Username *string `json:"username" example:"fossy"` + UserEmail *string `json:"-"` + Userlevel *string `json:"user_level" validate:"omitempty,oneof=USER ADMIN" example:"ADMIN"` + Userpassword *string `json:"user_password"` + Active *bool `json:"active"` + OidcSub *string `json:"-"` + OidcIss *string `json:"-"` +} + +type OidcUserCreate struct { + Token string `json:"token"` } type UserLogin struct { diff --git a/pkg/utils/util.go b/pkg/utils/util.go index 737b776..0dd8850 100644 --- a/pkg/utils/util.go +++ b/pkg/utils/util.go @@ -11,7 +11,6 @@ import ( "encoding/json" "errors" "fmt" - "html" "log" "net/http" "os" @@ -153,8 +152,6 @@ func HashPassword(user *models.User) error { } *user.Userpassword = string(hashedPassword) - user.Username = html.EscapeString(strings.TrimSpace(user.Username)) - return nil } @@ -370,7 +367,7 @@ func createObligationMapChangelog(tx *gorm.DB, username string, } var user models.User - if err := tx.Where(models.User{Username: username}).First(&user).Error; err != nil { + if err := tx.Where(models.User{Username: &username}).First(&user).Error; err != nil { return err }