From c426332533c9f9786c403b338554753a5bc01891 Mon Sep 17 00:00:00 2001 From: Gaurav Mishra Date: Fri, 5 Jan 2024 13:56:50 +0530 Subject: [PATCH 1/3] feat(auth): user bcrypt to encrypt user password Signed-off-by: Gaurav Mishra --- pkg/auth/auth.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++- pkg/utils/util.go | 25 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index ddde712..a911e38 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -13,6 +13,8 @@ import ( "strings" "time" + "golang.org/x/crypto/bcrypt" + "github.com/gin-gonic/gin" "github.com/fossology/LicenseDb/pkg/db" @@ -54,6 +56,19 @@ func CreateUser(c *gin.Context) { Userpassword: input.Userpassword, } + err := utils.HashPassword(&user) + 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 + } + result := db.DB.Where(models.User{Username: user.Username}).FirstOrCreate(&user) if result.Error != nil { er := models.LicenseError{ @@ -231,8 +246,24 @@ func AuthenticationMiddleware() gin.HandlerFunc { return } + err = EncryptUserPassword(&user) + if err != nil { + er := models.LicenseError{ + Status: http.StatusInternalServerError, + Message: "Failed to encrypt user password", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + + c.JSON(http.StatusInternalServerError, er) + c.Abort() + return + } + // Check if the password matches - if *user.Userpassword != password { + err = utils.VerifyPassword(password, *user.Userpassword) + if err != nil { er := models.LicenseError{ Status: http.StatusUnauthorized, Message: "Incorrect password", @@ -266,3 +297,23 @@ func CORSMiddleware() gin.HandlerFunc { c.Next() } } + +// EncryptUserPassword checks if the password is already encrypted or not. If +// not, it encrypts the password. +func EncryptUserPassword(user *models.User) error { + _, err := bcrypt.Cost([]byte(*user.Userpassword)) + if err == nil { + return nil + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*user.Userpassword), bcrypt.DefaultCost) + + if err != nil { + return err + } + *user.Userpassword = string(hashedPassword) + + db.DB.Model(&user).Updates(user) + + return nil +} diff --git a/pkg/utils/util.go b/pkg/utils/util.go index 14d5022..de71ccf 100644 --- a/pkg/utils/util.go +++ b/pkg/utils/util.go @@ -8,10 +8,14 @@ package utils import ( "fmt" + "html" "net/http" "strconv" + "strings" "time" + "golang.org/x/crypto/bcrypt" + "github.com/gin-gonic/gin" "github.com/fossology/LicenseDb/pkg/models" @@ -121,3 +125,24 @@ func ParseIdToInt(c *gin.Context, id string, idType string) (int64, error) { } return parsedId, nil } + +// HashPassword hashes the password of the user using bcrypt. It also trims the +// username and escapes the HTML characters. +func HashPassword(user *models.User) error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*user.Userpassword), bcrypt.DefaultCost) + + if err != nil { + return err + } + *user.Userpassword = string(hashedPassword) + + user.Username = html.EscapeString(strings.TrimSpace(user.Username)) + + return nil +} + +// VerifyPassword compares the input password with the password stored in the +// database. Returns nil on success, or an error on failure. +func VerifyPassword(inputPassword, dbPassword string) error { + return bcrypt.CompareHashAndPassword([]byte(dbPassword), []byte(inputPassword)) +} From dba59dc8787d439dd1ac43991b62ae219bc980d9 Mon Sep 17 00:00:00 2001 From: Gaurav Mishra Date: Fri, 5 Jan 2024 15:07:04 +0530 Subject: [PATCH 2/3] feat(auth): use JWT tokens Signed-off-by: Gaurav Mishra --- .env | 6 ++ cmd/laas/docs/docs.go | 91 +++++++++++++++---- cmd/laas/docs/swagger.json | 93 +++++++++++++++---- cmd/laas/docs/swagger.yaml | 72 +++++++++++---- cmd/laas/main.go | 8 ++ go.mod | 4 +- go.sum | 4 + pkg/api/api.go | 9 +- pkg/api/audit.go | 8 +- pkg/api/licenses.go | 4 +- pkg/api/obligationmap.go | 4 +- pkg/api/obligations.go | 6 +- pkg/auth/auth.go | 181 ++++++++++++++++++++++++++++--------- pkg/models/types.go | 5 + 14 files changed, 389 insertions(+), 106 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..4db8d60 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: GPL-2.0-only + +# How long the token can be valid +TOKEN_HOUR_LIFESPAN=24 +# Secret key to sign tokens (openssl rand -hex 32) +API_SECRET=some-random-string diff --git a/cmd/laas/docs/docs.go b/cmd/laas/docs/docs.go index b9c6ce9..0750b43 100644 --- a/cmd/laas/docs/docs.go +++ b/cmd/laas/docs/docs.go @@ -27,7 +27,7 @@ const docTemplate = `{ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get all audit records from the server", @@ -62,7 +62,7 @@ const docTemplate = `{ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get a specific audit records by ID", @@ -112,7 +112,7 @@ const docTemplate = `{ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get changelogs of an audit record", @@ -162,7 +162,7 @@ const docTemplate = `{ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get a specific changelog of an audit record by its ID", @@ -333,7 +333,7 @@ const docTemplate = `{ "post": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Create a new license in the service", @@ -428,7 +428,7 @@ const docTemplate = `{ "patch": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Update a license in the service", @@ -495,6 +495,46 @@ const docTemplate = `{ } } }, + "/login": { + "post": { + "description": "Login to get JWT token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Login", + "operationId": "Login", + "parameters": [ + { + "description": "Login credentials", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UserLogin" + } + } + ], + "responses": { + "200": { + "description": "JWT token", + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + } + } + } + } + }, "/obligation_maps/license/{license}": { "get": { "description": "Get obligation maps for a given license shortname", @@ -577,7 +617,7 @@ const docTemplate = `{ "put": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Replaces the license list of an obligation topic with the given list in the obligation map.", @@ -634,7 +674,7 @@ const docTemplate = `{ "patch": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Add or remove licenses from obligation map for a given obligation topic", @@ -736,7 +776,7 @@ const docTemplate = `{ "post": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Create an obligation and associate it with licenses", @@ -831,7 +871,7 @@ const docTemplate = `{ "delete": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Deactivate an obligation", @@ -870,7 +910,7 @@ const docTemplate = `{ "patch": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Update an existing obligation record", @@ -982,7 +1022,7 @@ const docTemplate = `{ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get all service users", @@ -1015,7 +1055,7 @@ const docTemplate = `{ "post": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Create a new service user", @@ -1067,7 +1107,7 @@ const docTemplate = `{ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get a single user by ID", @@ -1812,6 +1852,22 @@ const docTemplate = `{ } } }, + "models.UserLogin": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string", + "example": "fossy" + } + } + }, "models.UserResponse": { "type": "object", "properties": { @@ -1832,8 +1888,11 @@ const docTemplate = `{ } }, "securityDefinitions": { - "BasicAuth": { - "type": "basic" + "ApiKeyAuth": { + "description": "Token from /login endpoint", + "type": "apiKey", + "name": "Authorization", + "in": "header" } } }` diff --git a/cmd/laas/docs/swagger.json b/cmd/laas/docs/swagger.json index 6ee8850..e4932df 100644 --- a/cmd/laas/docs/swagger.json +++ b/cmd/laas/docs/swagger.json @@ -21,7 +21,7 @@ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get all audit records from the server", @@ -56,7 +56,7 @@ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get a specific audit records by ID", @@ -106,7 +106,7 @@ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get changelogs of an audit record", @@ -156,7 +156,7 @@ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get a specific changelog of an audit record by its ID", @@ -327,7 +327,7 @@ "post": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Create a new license in the service", @@ -422,7 +422,7 @@ "patch": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Update a license in the service", @@ -489,6 +489,46 @@ } } }, + "/login": { + "post": { + "description": "Login to get JWT token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Login", + "operationId": "Login", + "parameters": [ + { + "description": "Login credentials", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UserLogin" + } + } + ], + "responses": { + "200": { + "description": "JWT token", + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + } + } + } + } + }, "/obligation_maps/license/{license}": { "get": { "description": "Get obligation maps for a given license shortname", @@ -571,7 +611,7 @@ "put": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Replaces the license list of an obligation topic with the given list in the obligation map.", @@ -628,7 +668,7 @@ "patch": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Add or remove licenses from obligation map for a given obligation topic", @@ -730,7 +770,7 @@ "post": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Create an obligation and associate it with licenses", @@ -825,7 +865,7 @@ "delete": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Deactivate an obligation", @@ -864,7 +904,7 @@ "patch": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Update an existing obligation record", @@ -976,7 +1016,7 @@ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get all service users", @@ -1009,7 +1049,7 @@ "post": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Create a new service user", @@ -1061,7 +1101,7 @@ "get": { "security": [ { - "BasicAuth": [] + "ApiKeyAuth": [] } ], "description": "Get a single user by ID", @@ -1806,6 +1846,22 @@ } } }, + "models.UserLogin": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string", + "example": "fossy" + } + } + }, "models.UserResponse": { "type": "object", "properties": { @@ -1826,8 +1882,11 @@ } }, "securityDefinitions": { - "BasicAuth": { - "type": "basic" + "ApiKeyAuth": { + "description": "Token from /login endpoint", + "type": "apiKey", + "name": "Authorization", + "in": "header" } } -} +} \ No newline at end of file diff --git a/cmd/laas/docs/swagger.yaml b/cmd/laas/docs/swagger.yaml index fc91e8d..b692274 100644 --- a/cmd/laas/docs/swagger.yaml +++ b/cmd/laas/docs/swagger.yaml @@ -492,6 +492,17 @@ definitions: - userlevel - username type: object + models.UserLogin: + properties: + password: + type: string + username: + example: fossy + type: string + required: + - password + - username + type: object models.UserResponse: properties: data: @@ -536,7 +547,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Get audit records tags: - Audits @@ -568,7 +579,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Get an audit record tags: - Audits @@ -600,7 +611,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Get changelogs tags: - Audits @@ -637,7 +648,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Get a changelog tags: - Audits @@ -750,7 +761,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Create a new license tags: - Licenses @@ -821,10 +832,36 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Update a license tags: - Licenses + /login: + post: + consumes: + - application/json + description: Login to get JWT token + operationId: Login + parameters: + - description: Login credentials + in: body + name: user + required: true + schema: + $ref: '#/definitions/models.UserLogin' + produces: + - application/json + responses: + "200": + description: JWT token + schema: + properties: + token: + type: string + type: object + summary: Login + tags: + - Users /obligation_maps/license/{license}: get: consumes: @@ -916,7 +953,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Add or remove licenses from obligation map tags: - Obligations @@ -954,7 +991,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Change license list tags: - Obligations @@ -1016,7 +1053,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Create an obligation tags: - Obligations @@ -1042,7 +1079,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Deactivate obligation tags: - Obligations @@ -1108,7 +1145,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Update obligation tags: - Obligations @@ -1161,7 +1198,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Get users tags: - Users @@ -1193,7 +1230,7 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Create new user tags: - Users @@ -1225,11 +1262,14 @@ paths: schema: $ref: '#/definitions/models.LicenseError' security: - - BasicAuth: [] + - ApiKeyAuth: [] summary: Get a user tags: - Users securityDefinitions: - BasicAuth: - type: basic + ApiKeyAuth: + description: Token from /login endpoint + in: header + name: Authorization + type: apiKey swagger: "2.0" diff --git a/cmd/laas/main.go b/cmd/laas/main.go index e36d3ec..cf7463f 100644 --- a/cmd/laas/main.go +++ b/cmd/laas/main.go @@ -10,6 +10,8 @@ import ( "flag" "log" + "github.com/joho/godotenv" + _ "github.com/fossology/LicenseDb/cmd/laas/docs" "github.com/fossology/LicenseDb/pkg/api" "github.com/fossology/LicenseDb/pkg/db" @@ -35,6 +37,12 @@ var ( ) func main() { + err := godotenv.Load(".env") + + if err != nil { + log.Fatalf("Error loading .env file") + } + flag.Parse() db.Connect(dbhost, port, user, dbname, password) diff --git a/go.mod b/go.mod index 7a4f9d4..5de54af 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,13 @@ go 1.20 require ( github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.8.3 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.2 + golang.org/x/crypto v0.17.0 golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 gorm.io/driver/postgres v1.5.2 gorm.io/gorm v1.25.1 @@ -49,7 +52,6 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.17.0 // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index 605f5e6..0b08161 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 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.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= @@ -54,6 +56,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/pkg/api/api.go b/pkg/api/api.go index 104ae3e..890d318 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -35,7 +35,10 @@ import ( // @host localhost:8080 // @BasePath /api/v1 // -// @securityDefinitions.basic BasicAuth +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name Authorization +// @description Token from /login endpoint func Router() *gin.Engine { // r is a default instance of gin engine r := gin.Default() @@ -71,6 +74,10 @@ func Router() *gin.Engine { { health.GET("", GetHealth) } + login := unAuthorizedv1.Group("/login") + { + login.POST("", auth.Login) + } } authorizedv1 := r.Group("/api/v1") diff --git a/pkg/api/audit.go b/pkg/api/audit.go index 3edb7df..1661eaa 100644 --- a/pkg/api/audit.go +++ b/pkg/api/audit.go @@ -26,7 +26,7 @@ import ( // @Produce json // @Success 200 {object} models.AuditResponse "Audit records" // @Failure 404 {object} models.LicenseError "Not changelogs in DB" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /audits [get] func GetAllAudit(c *gin.Context) { var audit []models.Audit @@ -65,7 +65,7 @@ func GetAllAudit(c *gin.Context) { // @Success 200 {object} models.AuditResponse // @Failure 400 {object} models.LicenseError "Invalid audit ID" // @Failure 404 {object} models.LicenseError "No audit entry with given ID" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /audits/{audit_id} [get] func GetAudit(c *gin.Context) { var changelog models.Audit @@ -108,7 +108,7 @@ func GetAudit(c *gin.Context) { // @Success 200 {object} models.ChangeLogResponse // @Failure 400 {object} models.LicenseError "Invalid audit ID" // @Failure 404 {object} models.LicenseError "No audit entry with given ID" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /audits/{audit_id}/changes [get] func GetChangeLogs(c *gin.Context) { var changelog []models.ChangeLog @@ -153,7 +153,7 @@ func GetChangeLogs(c *gin.Context) { // @Success 200 {object} models.ChangeLogResponse // @Failure 400 {object} models.LicenseError "Invalid ID" // @Failure 404 {object} models.LicenseError "No changelog with given ID found" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /audits/{audit_id}/changes/{id} [get] func GetChangeLogbyId(c *gin.Context) { var changelog models.ChangeLog diff --git a/pkg/api/licenses.go b/pkg/api/licenses.go index d3af10c..1d2e8c2 100644 --- a/pkg/api/licenses.go +++ b/pkg/api/licenses.go @@ -239,7 +239,7 @@ func GetLicense(c *gin.Context) { // @Failure 400 {object} models.LicenseError "Invalid request body" // @Failure 409 {object} models.LicenseError "License with same shortname already exists" // @Failure 500 {object} models.LicenseError "Failed to create license" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /licenses [post] func CreateLicense(c *gin.Context) { var input models.LicenseInput @@ -329,7 +329,7 @@ func CreateLicense(c *gin.Context) { // @Failure 404 {object} models.LicenseError "License with shortname not found" // @Failure 409 {object} models.LicenseError "License with same shortname already exists" // @Failure 500 {object} models.LicenseError "Failed to update license" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /licenses/{shortname} [patch] func UpdateLicense(c *gin.Context) { var update models.LicenseUpdate diff --git a/pkg/api/obligationmap.go b/pkg/api/obligationmap.go index 11e1974..fb248c1 100644 --- a/pkg/api/obligationmap.go +++ b/pkg/api/obligationmap.go @@ -173,7 +173,7 @@ func GetObligationMapByLicense(c *gin.Context) { // @Failure 400 {object} models.LicenseError "Invalid json body" // @Failure 404 {object} models.LicenseError "No license or obligation found." // @Failure 500 {object} models.LicenseError "Failure to insert new maps" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /obligation_maps/topic/{topic}/license [patch] func PatchObligationMap(c *gin.Context) { var obligation models.Obligation @@ -261,7 +261,7 @@ func PatchObligationMap(c *gin.Context) { // @Success 200 {object} models.ObligationMapResponse // @Failure 400 {object} models.LicenseError "Invalid json body" // @Failure 404 {object} models.LicenseError "No license or obligation found." -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /obligation_maps/topic/{topic}/license [put] func UpdateLicenseInObligationMap(c *gin.Context) { var obligation models.Obligation diff --git a/pkg/api/obligations.go b/pkg/api/obligations.go index 081f253..1ffb22f 100644 --- a/pkg/api/obligations.go +++ b/pkg/api/obligations.go @@ -122,7 +122,7 @@ func GetObligation(c *gin.Context) { // @Failure 400 {object} models.LicenseError "Bad request body" // @Failure 409 {object} models.LicenseError "Obligation with same body exists" // @Failure 500 {object} models.LicenseError "Unable to create obligation" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /obligations [post] func CreateObligation(c *gin.Context) { var input models.ObligationInput @@ -218,7 +218,7 @@ func CreateObligation(c *gin.Context) { // @Failure 400 {object} models.LicenseError "Invalid request" // @Failure 404 {object} models.LicenseError "No obligation with given topic found" // @Failure 500 {object} models.LicenseError "Unable to update obligation" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /obligations/{topic} [patch] func UpdateObligation(c *gin.Context) { var update models.UpdateObligation @@ -386,7 +386,7 @@ func UpdateObligation(c *gin.Context) { // @Param topic path string true "Topic of the obligation to be updated" // @Success 204 // @Failure 404 {object} models.LicenseError "No obligation with given topic found" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /obligations/{topic} [delete] func DeleteObligation(c *gin.Context) { var obligation models.Obligation diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index a911e38..e7df985 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -7,12 +7,13 @@ package auth import ( - "encoding/base64" "fmt" "net/http" - "strings" + "os" + "strconv" "time" + "github.com/golang-jwt/jwt/v4" "golang.org/x/crypto/bcrypt" "github.com/gin-gonic/gin" @@ -34,7 +35,7 @@ import ( // @Success 201 {object} models.UserResponse // @Failure 400 {object} models.LicenseError "Invalid json body" // @Failure 409 {object} models.LicenseError "User already exists" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /users [post] func CreateUser(c *gin.Context) { var input models.UserInput @@ -113,7 +114,7 @@ func CreateUser(c *gin.Context) { // @Produce json // @Success 200 {object} models.UserResponse // @Failure 404 {object} models.LicenseError "Users not found" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /users [get] func GetAllUser(c *gin.Context) { var users []models.User @@ -155,7 +156,7 @@ func GetAllUser(c *gin.Context) { // @Success 200 {object} models.UserResponse // @Failure 400 {object} models.LicenseError "Invalid user id" // @Failure 404 {object} models.LicenseError "User not found" -// @Security BasicAuth +// @Security ApiKeyAuth // @Router /users/{id} [get] func GetUser(c *gin.Context) { var user models.User @@ -191,8 +192,9 @@ func GetUser(c *gin.Context) { // AuthenticationMiddleware is a middleware function for user authentication. func AuthenticationMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { + tokenString := c.GetHeader("Authorization") + + if tokenString == "" { er := models.LicenseError{ Status: http.StatusUnauthorized, Message: "Please check your credentials and try again", @@ -206,7 +208,13 @@ func AuthenticationMiddleware() gin.HandlerFunc { return } - decodedAuth, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic ")) + 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 + }) + if err != nil { er := models.LicenseError{ Status: http.StatusUnauthorized, @@ -221,22 +229,12 @@ func AuthenticationMiddleware() gin.HandlerFunc { return } - auth := strings.SplitN(string(decodedAuth), ":", 2) - if len(auth) != 2 { - c.AbortWithStatus(http.StatusBadRequest) - return - } - - username := auth[0] - password := auth[1] - - var user models.User - result := db.DB.Where(models.User{Username: username}).First(&user) - if result.Error != nil { + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { er := models.LicenseError{ Status: http.StatusUnauthorized, - Message: "User name not found", - Error: result.Error.Error(), + Message: "Invalid token", + Error: err.Error(), Path: c.Request.URL.Path, Timestamp: time.Now().Format(time.RFC3339), } @@ -246,28 +244,15 @@ func AuthenticationMiddleware() gin.HandlerFunc { return } - err = EncryptUserPassword(&user) - if err != nil { - er := models.LicenseError{ - Status: http.StatusInternalServerError, - Message: "Failed to encrypt user password", - Error: err.Error(), - Path: c.Request.URL.Path, - Timestamp: time.Now().Format(time.RFC3339), - } - - c.JSON(http.StatusInternalServerError, er) - c.Abort() - return - } + userId := int64(claims["id"].(float64)) - // Check if the password matches - err = utils.VerifyPassword(password, *user.Userpassword) - if err != nil { + var user models.User + result := db.DB.Where(models.User{Id: userId}).First(&user) + if result.Error != nil { er := models.LicenseError{ Status: http.StatusUnauthorized, - Message: "Incorrect password", - Error: "Password does not match", + Message: "User not found", + Error: err.Error(), Path: c.Request.URL.Path, Timestamp: time.Now().Format(time.RFC3339), } @@ -276,7 +261,8 @@ func AuthenticationMiddleware() gin.HandlerFunc { c.Abort() return } - c.Set("username", username) + + c.Set("username", user.Username) c.Next() } } @@ -298,9 +284,99 @@ func CORSMiddleware() gin.HandlerFunc { } } -// EncryptUserPassword checks if the password is already encrypted or not. If +// Login user and get JWT tokens +// +// @Summary Login +// @Description Login to get JWT token +// @Id Login +// @Tags Users +// @Accept json +// @Produce json +// @Param user body models.UserLogin true "Login credentials" +// @Success 200 {object} object{token=string} "JWT token" +// @Router /login [post] +func Login(c *gin.Context) { + var input models.UserLogin + 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 + } + + username := input.Username + password := input.Userpassword + + var user models.User + result := db.DB.Where(models.User{Username: username}).First(&user) + if result.Error != nil { + er := models.LicenseError{ + Status: http.StatusUnauthorized, + Message: "User name not found", + Error: "", + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + + c.JSON(http.StatusUnauthorized, er) + c.Abort() + return + } + + err := encryptUserPassword(&user) + if err != nil { + er := models.LicenseError{ + Status: http.StatusInternalServerError, + Message: "Failed to encrypt user password", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + + c.JSON(http.StatusInternalServerError, er) + c.Abort() + return + } + + // Check if the password matches + err = utils.VerifyPassword(password, *user.Userpassword) + if err != nil { + er := models.LicenseError{ + Status: http.StatusUnauthorized, + Message: "Incorrect password", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + + c.JSON(http.StatusUnauthorized, er) + c.Abort() + return + } + + token, err := generateToken(user) + if err != nil { + er := models.LicenseError{ + Status: http.StatusInternalServerError, + Message: "Failed to generate token", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusInternalServerError, er) + return + } + c.JSON(http.StatusOK, gin.H{"token": token}) +} + +// encryptUserPassword checks if the password is already encrypted or not. If // not, it encrypts the password. -func EncryptUserPassword(user *models.User) error { +func encryptUserPassword(user *models.User) error { _, err := bcrypt.Cost([]byte(*user.Userpassword)) if err == nil { return nil @@ -317,3 +393,20 @@ func EncryptUserPassword(user *models.User) error { return nil } + +// generateToken generates a JWT token for the user. +func generateToken(user models.User) (string, error) { + tokenLifespan, err := strconv.Atoi(os.Getenv("TOKEN_HOUR_LIFESPAN")) + + if err != nil { + return "", err + } + + claims := jwt.MapClaims{} + claims["id"] = user.Id + claims["nbf"] = time.Now().Unix() + claims["exp"] = time.Now().Add(time.Hour * time.Duration(tokenLifespan)).Unix() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + return token.SignedString([]byte(os.Getenv("API_SECRET"))) +} diff --git a/pkg/models/types.go b/pkg/models/types.go index d539e36..6cbc3dc 100644 --- a/pkg/models/types.go +++ b/pkg/models/types.go @@ -152,6 +152,11 @@ type UserInput struct { Userpassword *string `json:"password,omitempty" binding:"required"` } +type UserLogin struct { + Username string `json:"username" binding:"required" example:"fossy"` + Userpassword string `json:"password" binding:"required"` +} + // UserResponse struct is representation of design API response of user. type UserResponse struct { Status int `json:"status" example:"200"` From 89641c9fd568a367703e31bee3bd918759c90240 Mon Sep 17 00:00:00 2001 From: Gaurav Mishra Date: Fri, 5 Jan 2024 15:21:47 +0530 Subject: [PATCH 3/3] chore(readme): update README with new JWT changes Signed-off-by: Gaurav Mishra --- .env => .env.example | 1 + .gitignore | 1 + README.md | 61 +++++++++++++++++++++++++++++--------------- 3 files changed, 43 insertions(+), 20 deletions(-) rename .env => .env.example (78%) diff --git a/.env b/.env.example similarity index 78% rename from .env rename to .env.example index 4db8d60..602e613 100644 --- a/.env +++ b/.env.example @@ -1,4 +1,5 @@ # SPDX-License-Identifier: GPL-2.0-only +# SPDX-FileCopyrightText: FOSSology contributors # How long the token can be valid TOKEN_HOUR_LIFESPAN=24 diff --git a/.gitignore b/.gitignore index e96c94c..1bf2e63 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ *.so *.dylib /laas +/.env # Test binary, built with `go test -c` *.test diff --git a/README.md b/README.md index a9634fc..c2085f4 100644 --- a/README.md +++ b/README.md @@ -27,33 +27,35 @@ and changes. - **audits** table has the data of audits that are done in obligations or licenses - **change_logs** table has all the change history of a particular audit. -![alt text](./docs/assets/licensedb_erd.png) +![ER Diagram](./docs/assets/licensedb_erd.png) -### APIs +## APIs There are multiple API endpoints for licenses, obligations, user and audit endpoints. ### API endpoints -| # | Method | API Endpoints | Examples | Descriptions | -| --- | --------- | ---------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------- | -| 1 | **GET** | `/api/licenses/:shortname` | /api/licenses/MIT | Gets all data related to licenses by their shortname | -| 2 | **GET** | `/api/licenses/` | /api/licenses/copyleft="t"&active="t" | Get filter the licenses as per the filters | -| 3 | **POST** | `/api/licenses` | /api/licenses | Create a license with unique shortname | -| 4 | **POST** | `/api/licenses/search` | /api/licenses/search | Get the licenses with the post request filtered by field, search term and type | -| 5 | **PATCH** | `/api/licenses/:shortname` | /api/licenses/MIT | It updates the particular fields as requested of the license with shortname | -| 6 | **GET** | `/api/users` | /api/users | Get all the users and their data | -| 7 | **GET** | `/api/users/:id` | /api/users/1 | Get data relate to user by its id | -| 8 | **POST** | `/api/users` | /api/users | Create a user with unique data | -| 9 | **GET** | `/api/obligations` | /api/obligations | Get all the obligations | -| 10 | **GET** | `/api/obligation/:topic` | /api/obligation/topic | Gets all data related to obligations by their topic | -| 11 | **POST** | `/api/obligations` | /api/obligations | Create an obligation as well as add it to obligation map | -| 12 | **PATCH** | `/api/obligations/:topic` | /api/obligations | It updates the particular fields as requested of the obligation with topic | -| 13 | **GET** | `/api/audit` | /api/audit | Get the audit history of all the licenses and obligations | -| 14 | **GET** | `/api/audit/:audit_id` | /api/audit/1 | Get the data of a particular audit by its id | -| 15 | **GET** | `/api/audit/:audit_id/changes` | /api/audit/1/changes | Get the change logs of the particular audit id | -| 16 | **GET** | `/api/audit/:audit_id/changes/:id` | /api/audit/1/changes/2 | Get a particular change log of the particular audit id | +Check the OpenAPI documentation for the API endpoints at +[cmd/laas/docs/swagger.yaml](https://github.com/fossology/LicenseDb/blob/main/cmd/laas/docs/swagger.yaml). + +The same can be viewed by Swagger UI plugin after installing and running the +tool at [http://localhost:8080/swagger/index.html](http://localhost:8080/swagger/index.html). + +### Authentication + +To get the access token, send a POST request to `/api/v1/login` with the +username and password. + +```bash +curl -X POST "http://localhost:8080/api/v1/login" \ +-H "accept: application/json" -H "Content-Type: application/json" \ +-d "{ \"username\": \"\", \"password\": \"\"}" +``` + +As the response of the request, a JWT will be returned. Use this JWT with the +`Authorization` header (as `-H "Authorization: "`) to access endpoints +requiring authentication. ## Prerequisite @@ -75,6 +77,14 @@ cd LicenseDb go build ./cmd/laas ``` +- Create the `.env` file in the root directory of the project and change the + values of the environment variables as per your requirement. + +```bash +cp .env.example .env +vim .env +``` + - Run the executable. ```bash @@ -87,6 +97,17 @@ go build ./cmd/laas go run ./cmd/laas ``` +### Create first user +Connect to the database using `psql` with the following command. +```bash +psql -h localhost -p 5432 -U fossy -d fossology +``` + +Run the following query to create the first user. +```sql +INSERT INTO users (username, userpassword, userlevel) VALUES ('', '', 'admin'); +``` + ### Generating Swagger Documentation 1. Install [swag](https://github.com/swaggo/swag) using the following command. ```bash