From 4c1b35195ec45ceb374f27b60b236f890cf47923 Mon Sep 17 00:00:00 2001 From: Isaque Veras <46972789+isaqueveras@users.noreply.github.com> Date: Wed, 8 Mar 2023 20:55:28 -0300 Subject: [PATCH] :closed_lock_with_key: implementing authentication with OTP (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: :closed_lock_with_key: implementing authentication with OTP * fix: fixing error function name * test: ✅ add benchmark testing on package otp * feat: 📄 add license on file top * feat: add function to get qr code url * feat: ✨ route to fetch qr code url to configure a user's otp * docs: 📝 update documentation swag * feat: 🔐 adding validation to access a user's data * fix: test * feat: ✨ add function to configure otp * fix: fix test * fix: fix test 2 * feat: add command to run test in makefile * feat: update lib gopowersso * fix: remove translate not used * feat: using middleware SameUser in otp Router's * refact: remove validation same user (otp) * test: improving test of otp * refact: refactoring variables on business of otp * feat: add router of unconfigure otp * test: add test of the router of unconfigure the otp * feat: add router of login steps * test: add test of router the login steps * feat: update docs * fix: remove comment TODO --- Makefile | 3 + README.md | 2 + docs/docs.go | 168 +++++++++++++++++- docs/swagger.json | 166 ++++++++++++++++- docs/swagger.yaml | 110 +++++++++++- go.mod | 2 +- go.sum | 4 + i18n/en_US.json | 3 +- i18n/es_ES.json | 3 +- i18n/pt_BR.json | 3 +- internal/application/auth/business.go | 43 ++++- internal/application/auth/model.go | 13 +- .../application/auth/user/otp/business.go | 90 ++++++++++ internal/application/auth/user/otp/model.go | 10 ++ internal/domain/auth/errors.go | 5 + internal/domain/auth/interface.go | 1 + internal/domain/auth/model.go | 64 ++++--- internal/domain/auth/user/model.go | 15 ++ internal/domain/auth/user/otp/interface.go | 14 ++ internal/infrastructure/auth/postgres.go | 14 ++ internal/infrastructure/auth/repository.go | 5 + .../infrastructure/auth/user/otp/postgres.go | 61 +++++++ .../auth/user/otp/repository.go | 37 ++++ internal/infrastructure/auth/user/postgres.go | 23 +-- internal/interface/http/auth/handler.go | 22 +++ internal/interface/http/auth/handler_test.go | 43 ++++- internal/interface/http/auth/router.go | 2 + .../interface/http/auth/user/otp/handler.go | 98 ++++++++++ .../http/auth/user/otp/handler_test.go | 142 +++++++++++++++ .../interface/http/auth/user/otp/router.go | 19 ++ internal/interface/http/auth/user/router.go | 8 +- internal/middleware/metric.go | 5 +- .../20220812135531_create_table_users.up.sql | 11 +- otp/opt.go | 102 +++++++++++ otp/otp_test.go | 87 +++++++++ pkg/oops/error_handling.go | 9 + 36 files changed, 1329 insertions(+), 78 deletions(-) create mode 100644 internal/application/auth/user/otp/business.go create mode 100644 internal/application/auth/user/otp/model.go create mode 100644 internal/domain/auth/user/otp/interface.go create mode 100644 internal/infrastructure/auth/user/otp/postgres.go create mode 100644 internal/infrastructure/auth/user/otp/repository.go create mode 100644 internal/interface/http/auth/user/otp/handler.go create mode 100644 internal/interface/http/auth/user/otp/handler_test.go create mode 100644 internal/interface/http/auth/user/otp/router.go create mode 100644 otp/opt.go create mode 100644 otp/otp_test.go diff --git a/Makefile b/Makefile index c8fab76..9e8b34f 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,9 @@ DB_LOCAL := "postgres://postgres:postgres@localhost:5432/power-sso?sslmode=disab run: go run main.go +test: + go test ./... + local: docker compose -f docker-compose.local.yml up -d --build diff --git a/README.md b/README.md index b581a9d..c4d2108 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@

PowerSSO is a fundamental piece that authenticates and manages users with the possibility of integration between systems using a Rest API and gRPC

+ Go report + Repository size GitHub language count GitHub top language diff --git a/docs/docs.go b/docs/docs.go index 9279949..befc7a1 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1,6 +1,6 @@ // GENERATED BY THE COMMAND ABOVE; DO NOT EDIT // This file was generated by swaggo/swag at -// 2023-02-11 16:58:20.668754441 -0300 -03 m=+0.019358123 +// 2023-03-08 20:50:40.624770349 -0300 -03 m=+0.303424087 package docs @@ -79,6 +79,30 @@ var doc = `{ } } }, + "/v1/auth/login/steps": { + "get": { + "description": "Steps to login", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Http/Auth" + ], + "summary": "Steps to login", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "$ref": "#/definitions/auth.StepsResponse" + } + } + } + } + }, "/v1/auth/logout": { "delete": { "description": "Route to logout a user account into the system", @@ -127,6 +151,111 @@ var doc = `{ } } }, + "/v1/auth/user/{user_uuid}/disable": { + "put": { + "description": "Route to disable a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Http/Auth/User" + ], + "summary": "Disable user", + "parameters": [ + { + "type": "string", + "description": "UUID of the user", + "name": "user_uuid", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "$ref": "#/definitions/utils.NoContent" + } + } + } + } + }, + "/v1/auth/user/{user_uuid}/otp/configure": { + "post": { + "description": "Configure a user's OTP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Http/Auth/OTP" + ], + "summary": "Configure a user's OTP", + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "$ref": "#/definitions/utils.NoContent" + } + } + } + } + }, + "/v1/auth/user/{user_uuid}/otp/qrcode": { + "get": { + "description": "Configure a user's OTP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Http/Auth/OTP" + ], + "summary": "Configure a user's OTP", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "$ref": "#/definitions/otp.QRCodeResponse" + } + } + } + } + }, + "/v1/auth/user/{user_uuid}/otp/unconfigure": { + "put": { + "description": "unconfigure a user's OTP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Http/Auth/OTP" + ], + "summary": "unconfigure a user's OTP", + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "$ref": "#/definitions/utils.NoContent" + } + } + } + } + }, "/v1/project/create": { "post": { "description": "Register a project including several users to the project", @@ -165,6 +294,9 @@ var doc = `{ "created_at": { "type": "string" }, + "data": { + "type": "object" + }, "email": { "type": "string" }, @@ -174,21 +306,21 @@ var doc = `{ "first_name": { "type": "string" }, - "jwt_token": { - "type": "string" - }, "last_name": { "type": "string" }, "level": { "type": "string" }, + "otp_enabled": { + "type": "boolean" + }, + "otp_setup": { + "type": "boolean" + }, "phone_number": { "type": "string" }, - "raw_data": { - "type": "object" - }, "roles": { "type": "array", "items": { @@ -198,11 +330,33 @@ var doc = `{ "session_id": { "type": "string" }, + "token": { + "type": "string" + }, "user_id": { "type": "string" } } }, + "auth.StepsResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "otp": { + "type": "boolean" + } + } + }, + "otp.QRCodeResponse": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, "utils.NoContent": { "type": "object" } diff --git a/docs/swagger.json b/docs/swagger.json index 0359cc3..1a7fd63 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -64,6 +64,30 @@ } } }, + "/v1/auth/login/steps": { + "get": { + "description": "Steps to login", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Http/Auth" + ], + "summary": "Steps to login", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "$ref": "#/definitions/auth.StepsResponse" + } + } + } + } + }, "/v1/auth/logout": { "delete": { "description": "Route to logout a user account into the system", @@ -112,6 +136,111 @@ } } }, + "/v1/auth/user/{user_uuid}/disable": { + "put": { + "description": "Route to disable a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Http/Auth/User" + ], + "summary": "Disable user", + "parameters": [ + { + "type": "string", + "description": "UUID of the user", + "name": "user_uuid", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "$ref": "#/definitions/utils.NoContent" + } + } + } + } + }, + "/v1/auth/user/{user_uuid}/otp/configure": { + "post": { + "description": "Configure a user's OTP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Http/Auth/OTP" + ], + "summary": "Configure a user's OTP", + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "$ref": "#/definitions/utils.NoContent" + } + } + } + } + }, + "/v1/auth/user/{user_uuid}/otp/qrcode": { + "get": { + "description": "Configure a user's OTP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Http/Auth/OTP" + ], + "summary": "Configure a user's OTP", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "$ref": "#/definitions/otp.QRCodeResponse" + } + } + } + } + }, + "/v1/auth/user/{user_uuid}/otp/unconfigure": { + "put": { + "description": "unconfigure a user's OTP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Http/Auth/OTP" + ], + "summary": "unconfigure a user's OTP", + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "$ref": "#/definitions/utils.NoContent" + } + } + } + } + }, "/v1/project/create": { "post": { "description": "Register a project including several users to the project", @@ -150,6 +279,9 @@ "created_at": { "type": "string" }, + "data": { + "type": "object" + }, "email": { "type": "string" }, @@ -159,21 +291,21 @@ "first_name": { "type": "string" }, - "jwt_token": { - "type": "string" - }, "last_name": { "type": "string" }, "level": { "type": "string" }, + "otp_enabled": { + "type": "boolean" + }, + "otp_setup": { + "type": "boolean" + }, "phone_number": { "type": "string" }, - "raw_data": { - "type": "object" - }, "roles": { "type": "array", "items": { @@ -183,11 +315,33 @@ "session_id": { "type": "string" }, + "token": { + "type": "string" + }, "user_id": { "type": "string" } } }, + "auth.StepsResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "otp": { + "type": "boolean" + } + } + }, + "otp.QRCodeResponse": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, "utils.NoContent": { "type": "object" } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d36f4f5..ee584eb 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -8,31 +8,47 @@ definitions: type: string created_at: type: string + data: + type: object email: type: string expires_at: type: string first_name: type: string - jwt_token: - type: string last_name: type: string level: type: string + otp_enabled: + type: boolean + otp_setup: + type: boolean phone_number: type: string - raw_data: - type: object roles: items: type: string type: array session_id: type: string + token: + type: string user_id: type: string type: object + auth.StepsResponse: + properties: + name: + type: string + otp: + type: boolean + type: object + otp.QRCodeResponse: + properties: + url: + type: string + type: object utils.NoContent: type: object host: localhost:5000 @@ -79,6 +95,22 @@ paths: summary: User login tags: - Http/Auth + /v1/auth/login/steps: + get: + consumes: + - application/json + description: Steps to login + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/auth.StepsResponse' + type: object + summary: Steps to login + tags: + - Http/Auth /v1/auth/logout: delete: consumes: @@ -111,6 +143,76 @@ paths: summary: Register a user tags: - Http/Auth + /v1/auth/user/{user_uuid}/disable: + put: + consumes: + - application/json + description: Route to disable a user + parameters: + - description: UUID of the user + in: path + name: user_uuid + required: true + type: string + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/utils.NoContent' + type: object + summary: Disable user + tags: + - Http/Auth/User + /v1/auth/user/{user_uuid}/otp/configure: + post: + consumes: + - application/json + description: Configure a user's OTP + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/utils.NoContent' + type: object + summary: Configure a user's OTP + tags: + - Http/Auth/OTP + /v1/auth/user/{user_uuid}/otp/qrcode: + get: + consumes: + - application/json + description: Configure a user's OTP + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/otp.QRCodeResponse' + type: object + summary: Configure a user's OTP + tags: + - Http/Auth/OTP + /v1/auth/user/{user_uuid}/otp/unconfigure: + put: + consumes: + - application/json + description: unconfigure a user's OTP + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/utils.NoContent' + type: object + summary: unconfigure a user's OTP + tags: + - Http/Auth/OTP /v1/project/create: post: consumes: diff --git a/go.mod b/go.mod index 31167e5..e38a65e 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/gosimple/slug v1.13.1 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/isaqueveras/endless v0.0.0-20170109170031-447134032cb6 - github.com/isaqueveras/go-powersso v0.0.0-20220821162303-e962cc527e1f + github.com/isaqueveras/go-powersso v0.0.0-20230308214228-19254da208d9 github.com/isaqueveras/lingo v0.0.0-20181220065520-bfdb55fa4143 github.com/jackc/pgx v3.6.2+incompatible github.com/microcosm-cc/bluemonday v1.0.22 diff --git a/go.sum b/go.sum index b559186..3dfcc30 100644 --- a/go.sum +++ b/go.sum @@ -222,6 +222,10 @@ github.com/isaqueveras/endless v0.0.0-20170109170031-447134032cb6 h1:rbjHNN2W5KI github.com/isaqueveras/endless v0.0.0-20170109170031-447134032cb6/go.mod h1:AD4iwpmo49YpuXxMKc/MGYporOhytbRzX3YJclZ50gM= github.com/isaqueveras/go-powersso v0.0.0-20220821162303-e962cc527e1f h1:KZ3LpyY2RgfkS2OTirIV8p8X8dyljeu9orv8OwQOT84= github.com/isaqueveras/go-powersso v0.0.0-20220821162303-e962cc527e1f/go.mod h1:FnfqWwNIy8L2Q7tnkGswgEg9jUaRKCN7inKlcPHXQ0I= +github.com/isaqueveras/go-powersso v0.0.0-20230226170047-978a96a9547c h1:UhC2FZjaaHuNDcnRxU6rRzK2BpFkYHMhkGiec+uhh1c= +github.com/isaqueveras/go-powersso v0.0.0-20230226170047-978a96a9547c/go.mod h1:FnfqWwNIy8L2Q7tnkGswgEg9jUaRKCN7inKlcPHXQ0I= +github.com/isaqueveras/go-powersso v0.0.0-20230308214228-19254da208d9 h1:NDBuHRtkhRDDbejcciqbCcx+INTeaPpe53TeCjTuA/0= +github.com/isaqueveras/go-powersso v0.0.0-20230308214228-19254da208d9/go.mod h1:FnfqWwNIy8L2Q7tnkGswgEg9jUaRKCN7inKlcPHXQ0I= github.com/isaqueveras/lingo v0.0.0-20181220065520-bfdb55fa4143 h1:NAZUrnBsH12EekeL3lCZ43Ktg+kuHyTLIFBNfemJvHY= github.com/isaqueveras/lingo v0.0.0-20181220065520-bfdb55fa4143/go.mod h1:zC4t5LIN8+XKUeXLkRNj0AS0bTva3RoVpd0D8Vlt6dY= github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc= diff --git a/i18n/en_US.json b/i18n/en_US.json index 26c24a4..959aa54 100644 --- a/i18n/en_US.json +++ b/i18n/en_US.json @@ -33,7 +33,8 @@ "err_not_have_permission_login": "You do not have permission to login", "err_user_not_exists": "User not found", "err_email_or_password_is_not_valid": "Email or password is not valid", - "err_user_blocked_temporarily": "User is blocked temporarily" + "err_user_blocked_temporarily": "User is blocked temporarily", + "err_otp_token_invalid": "Token OTP invalid" } } } \ No newline at end of file diff --git a/i18n/es_ES.json b/i18n/es_ES.json index a740506..dca05c9 100644 --- a/i18n/es_ES.json +++ b/i18n/es_ES.json @@ -33,7 +33,8 @@ "err_not_have_permission_login": "No tienes permiso para iniciar sesión", "err_user_not_exists": "El usuario no existe", "err_email_or_password_is_not_valid": "El correo electrónico o la contraseña no son válidos", - "err_user_blocked_temporarily": "El usuario está bloqueado temporalmente" + "err_user_blocked_temporarily": "El usuario está bloqueado temporalmente", + "err_otp_token_invalid": "Token OTP inválido" } } } \ No newline at end of file diff --git a/i18n/pt_BR.json b/i18n/pt_BR.json index 46d6216..7845c0d 100644 --- a/i18n/pt_BR.json +++ b/i18n/pt_BR.json @@ -33,7 +33,8 @@ "err_not_have_permission_login": "Você não tem permissão para fazer login", "err_user_not_exists": "Usuário não encontrado", "err_email_or_password_is_not_valid": "E-mail ou senha inválidos", - "err_user_blocked_temporarily": "Usuário bloqueado temporariamente" + "err_user_blocked_temporarily": "Usuário bloqueado temporariamente", + "err_otp_token_invalid": "Token OTP inválido" } } } \ No newline at end of file diff --git a/internal/application/auth/business.go b/internal/application/auth/business.go index 8773c0d..fb3d669 100644 --- a/internal/application/auth/business.go +++ b/internal/application/auth/business.go @@ -15,6 +15,7 @@ import ( infraRoles "github.com/isaqueveras/power-sso/internal/infrastructure/auth/roles" infraSession "github.com/isaqueveras/power-sso/internal/infrastructure/auth/session" infraUser "github.com/isaqueveras/power-sso/internal/infrastructure/auth/user" + "github.com/isaqueveras/power-sso/otp" "github.com/isaqueveras/power-sso/pkg/conversor" "github.com/isaqueveras/power-sso/pkg/database/postgres" "github.com/isaqueveras/power-sso/pkg/mailer" @@ -197,6 +198,12 @@ func Login(ctx context.Context, in *LoginRequest) (*SessionResponse, error) { return nil, oops.Err(domain.ErrNotHavePermissionLogin()) } + if user.OTPConfiguredAndEnabled() { + if err = otp.ValidateToken(user.OTPToken, in.OTP); err != nil { + return nil, oops.Err(domain.ErrOTPTokenInvalid()) + } + } + if sessionID, err = repoSession.Create(user.ID, &in.ClientIP, &in.UserAgent); err != nil { return nil, oops.Err(err) } @@ -216,6 +223,8 @@ func Login(ctx context.Context, in *LoginRequest) (*SessionResponse, error) { Email: user.Email, FirstName: user.FirstName, LastName: user.LastName, + OTPEnabled: user.OTPEnabled, + OTPSetUp: user.OTPSetup, Roles: userRoles.Arrays(), About: user.About, AvatarURL: user.Avatar, @@ -235,21 +244,45 @@ func Login(ctx context.Context, in *LoginRequest) (*SessionResponse, error) { // Logout is the business logic for the user logout func Logout(ctx context.Context, sessionID *string) (err error) { - var transaction *postgres.DBTransaction - if transaction, err = postgres.NewTransaction(ctx, false); err != nil { + var tx *postgres.DBTransaction + if tx, err = postgres.NewTransaction(ctx, false); err != nil { return oops.Err(err) } - defer transaction.Rollback() + defer tx.Rollback() if err = infraSession. - New(transaction). + New(tx). Delete(sessionID); err != nil { return oops.Err(err) } - if err = transaction.Commit(); err != nil { + if err = tx.Commit(); err != nil { return oops.Err(err) } return } + +// LoginSteps is the business logic needed to retrieve needed steps for log a user in +func LoginSteps(ctx context.Context, email *string) (res *StepsResponse, err error) { + var tx *postgres.DBTransaction + if tx, err = postgres.NewTransaction(ctx, true); err != nil { + return nil, oops.Err(err) + } + defer tx.Rollback() + + var ( + repository = auth.New(tx, nil) + steps *domain.Steps + ) + + if steps, err = repository.LoginSteps(email); err != nil { + return nil, oops.Err(err) + } + + res = new(StepsResponse) + res.Name = steps.Name + res.OTP = steps.OTP + + return +} diff --git a/internal/application/auth/model.go b/internal/application/auth/model.go index 514ec4d..5fe372e 100644 --- a/internal/application/auth/model.go +++ b/internal/application/auth/model.go @@ -49,12 +49,20 @@ type ( About *string `json:"about,omitempty"` AvatarURL *string `json:"avatar_url,omitempty"` PhoneNumber *string `json:"phone_number,omitempty"` + OTPEnabled *bool `json:"otp_enabled,omitempty"` + OTPSetUp *bool `json:"otp_setup,omitempty"` Roles []string `json:"roles,omitempty"` - Token *string `json:"jwt_token,omitempty"` - RawData map[string]any `json:"raw_data,omitempty"` + Token *string `json:"token,omitempty"` + RawData map[string]any `json:"data,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } + + // StepsResponse returns the data for login + StepsResponse struct { + Name *string `json:"name,omitempty"` + OTP *bool `json:"otp,omitempty"` + } ) // Prepare prepare data for registration @@ -107,6 +115,7 @@ func (rr *RegisterRequest) RefreshTokenKey() { type LoginRequest struct { Email *string `json:"email" binding:"required,lte=60,email"` Password *string `json:"password" binding:"required,gte=6"` + OTP *string `json:"otp,omitempty"` ClientIP string `json:"-"` UserAgent string `json:"-"` diff --git a/internal/application/auth/user/otp/business.go b/internal/application/auth/user/otp/business.go new file mode 100644 index 0000000..dac7034 --- /dev/null +++ b/internal/application/auth/user/otp/business.go @@ -0,0 +1,90 @@ +// Copyright (c) 2023 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package otp + +import ( + "context" + "encoding/base32" + + "github.com/google/uuid" + + "github.com/isaqueveras/power-sso/config" + infraOTP "github.com/isaqueveras/power-sso/internal/infrastructure/auth/user/otp" + "github.com/isaqueveras/power-sso/internal/utils" + "github.com/isaqueveras/power-sso/otp" + "github.com/isaqueveras/power-sso/pkg/database/postgres" + "github.com/isaqueveras/power-sso/pkg/oops" + "github.com/isaqueveras/power-sso/pkg/security" +) + +// Configure performs business logic to configure otp for a user +func Configure(ctx context.Context, userID *uuid.UUID) (err error) { + var tx *postgres.DBTransaction + if tx, err = postgres.NewTransaction(ctx, false); err != nil { + return oops.Err(err) + } + defer tx.Rollback() + + data := []byte(security.RandomString(26)) + dst := make([]byte, base32.StdEncoding.EncodedLen(len(data))) + base32.StdEncoding.Encode(dst, data) + + repository := infraOTP.New(tx) + if err = repository.Configure(userID, utils.GetStringPointer(string(dst))); err != nil { + return oops.Err(err) + } + + if err = tx.Commit(); err != nil { + return oops.Err(err) + } + + return +} + +// Unconfigure performs business logic to unconfigure otp for a user +func Unconfigure(ctx context.Context, userID *uuid.UUID) (err error) { + var tx *postgres.DBTransaction + if tx, err = postgres.NewTransaction(ctx, false); err != nil { + return oops.Err(err) + } + defer tx.Rollback() + + repository := infraOTP.New(tx) + if err = repository.Unconfigure(userID); err != nil { + return oops.Err(err) + } + + if err = tx.Commit(); err != nil { + return oops.Err(err) + } + + return +} + +// GetQrCode performs business logic to get qrcode url +func GetQrCode(ctx context.Context, userID *uuid.UUID) (res *QRCodeResponse, err error) { + var ( + tx *postgres.DBTransaction + userName, token *string + ) + + if tx, err = postgres.NewTransaction(ctx, true); err != nil { + return nil, oops.Err(err) + } + defer tx.Rollback() + + if userName, token, err = infraOTP.New(tx).GetToken(userID); err != nil { + return nil, oops.Err(err) + } + + if config.Get().Server.IsModeDevelopment() { + *userName += " [DEV]" + } + + res = new(QRCodeResponse) + res.Url = utils.GetStringPointer(otp.GetUrlQrCode(*token, *userName)) + + return +} diff --git a/internal/application/auth/user/otp/model.go b/internal/application/auth/user/otp/model.go new file mode 100644 index 0000000..4da4cc0 --- /dev/null +++ b/internal/application/auth/user/otp/model.go @@ -0,0 +1,10 @@ +// Copyright (c) 2023 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package otp + +// QRCodeResponse wraps the data to return the qr code url +type QRCodeResponse struct { + Url *string `json:"url,omitempty"` +} diff --git a/internal/domain/auth/errors.go b/internal/domain/auth/errors.go index 8d75f56..c8dafad 100644 --- a/internal/domain/auth/errors.go +++ b/internal/domain/auth/errors.go @@ -45,3 +45,8 @@ func ErrEmailOrPasswordIsNotValid() *oops.Error { func ErrUserBlockedTemporarily() *oops.Error { return oops.NewError(i18n.Value("errors.handling.err_user_blocked_temporarily"), http.StatusForbidden) } + +// ErrOTPTokenInvalid creates and returns an error when validate token OTP +func ErrOTPTokenInvalid() *oops.Error { + return oops.NewError(i18n.Value("errors.handling.err_otp_token_invalid"), http.StatusForbidden) +} diff --git a/internal/domain/auth/interface.go b/internal/domain/auth/interface.go index a5e9e60..f39cf05 100644 --- a/internal/domain/auth/interface.go +++ b/internal/domain/auth/interface.go @@ -13,4 +13,5 @@ type IAuth interface { CreateAccessToken(userID *string) (*string, error) MarkTokenAsUsed(token *string) error AddNumberFailedAttempts(userID *string) error + LoginSteps(email *string) (*Steps, error) } diff --git a/internal/domain/auth/model.go b/internal/domain/auth/model.go index 9f65060..c932a69 100644 --- a/internal/domain/auth/model.go +++ b/internal/domain/auth/model.go @@ -15,32 +15,40 @@ const ( CostHashPasswordDevelopment int = 1 ) -// Register model the data to register user in the database -type Register struct { - FirstName *string `sql:"first_name" json:"first_name"` - LastName *string `sql:"last_name" json:"last_name"` - Email *string `sql:"email" json:"email"` - Password *string `sql:"password" json:"password"` - Roles *string `sql:"roles" json:"-"` - About *string `sql:"about" json:"about"` - Avatar *string `sql:"avatar" json:"avatar"` - PhoneNumber *string `sql:"phone_number" json:"phone_number"` - Address *string `sql:"address" json:"address"` - City *string `sql:"city" json:"city"` - Country *string `sql:"country" json:"country"` - Gender *string `sql:"gender" json:"gender"` - Postcode *int `sql:"postcode" json:"postcode"` - Birthday *time.Time `sql:"birthday" json:"birthday"` - TokenKey *string `sql:"token_key" json:"token_key"` -} +type ( + // Register model the data to register user in the database + Register struct { + FirstName *string `sql:"first_name" json:"first_name"` + LastName *string `sql:"last_name" json:"last_name"` + Email *string `sql:"email" json:"email"` + Password *string `sql:"password" json:"password"` + Roles *string `sql:"roles" json:"-"` + About *string `sql:"about" json:"about"` + Avatar *string `sql:"avatar" json:"avatar"` + PhoneNumber *string `sql:"phone_number" json:"phone_number"` + Address *string `sql:"address" json:"address"` + City *string `sql:"city" json:"city"` + Country *string `sql:"country" json:"country"` + Gender *string `sql:"gender" json:"gender"` + Postcode *int `sql:"postcode" json:"postcode"` + Birthday *time.Time `sql:"birthday" json:"birthday"` + TokenKey *string `sql:"token_key" json:"token_key"` + } -// ActivateAccountToken model the data to activate user account -type ActivateAccountToken struct { - ID *string - UserID *string - Used *bool - IsValid *bool - ExpiresAt *time.Time - CreatedAt *time.Time - UpdatedAt *time.Time -} + // ActivateAccountToken model the data to activate user account + ActivateAccountToken struct { + ID *string + UserID *string + Used *bool + IsValid *bool + ExpiresAt *time.Time + CreatedAt *time.Time + UpdatedAt *time.Time + } + + // Steps contains login steps + Steps struct { + Name *string + OTP *bool + } +) diff --git a/internal/domain/auth/user/model.go b/internal/domain/auth/user/model.go index 503dd19..7ca7f37 100644 --- a/internal/domain/auth/user/model.go +++ b/internal/domain/auth/user/model.go @@ -37,8 +37,23 @@ type User struct { BlockedTemporarily *bool TokenKey *string IsActive *bool + OTPToken *string + OTPEnabled *bool + OTPSetup *bool Birthday *time.Time CreatedAt *time.Time UpdatedAt *time.Time LoginDate *time.Time } + +// OTPConfiguredAndEnabled checks if the user has the otp configured and activated for use +func (u *User) OTPConfiguredAndEnabled() bool { + enabled := u.OTPEnabled != nil && *u.OTPEnabled + setup := u.OTPSetup != nil && *u.OTPSetup + + if enabled && setup { + return true + } + + return false +} diff --git a/internal/domain/auth/user/otp/interface.go b/internal/domain/auth/user/otp/interface.go new file mode 100644 index 0000000..c9ce4bb --- /dev/null +++ b/internal/domain/auth/user/otp/interface.go @@ -0,0 +1,14 @@ +// Copyright (c) 2023 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package otp + +import "github.com/google/uuid" + +// IOTP define an interface for data layer access methods +type IOTP interface { + GetToken(userID *uuid.UUID) (*string, *string, error) + Configure(userID *uuid.UUID, secret *string) error + Unconfigure(userID *uuid.UUID) error +} diff --git a/internal/infrastructure/auth/postgres.go b/internal/infrastructure/auth/postgres.go index 0ae04d1..77369ed 100644 --- a/internal/infrastructure/auth/postgres.go +++ b/internal/infrastructure/auth/postgres.go @@ -121,3 +121,17 @@ func (pg *pgAuth) addNumberFailedAttempts(userID *string) (err error) { return } + +func (pg *pgAuth) loginSteps(email *string) (steps *auth.Steps, err error) { + steps = new(auth.Steps) + if err = pg.DB.Builder. + Select("COALESCE(otp AND otp_setup, FALSE), first_name"). + From("users"). + Where("email = ?", email). + Limit(1). + Scan(&steps.OTP, &steps.Name); err != nil && err != sql.ErrNoRows { + return nil, oops.Err(err) + } + + return +} diff --git a/internal/infrastructure/auth/repository.go b/internal/infrastructure/auth/repository.go index 40d6198..f663e01 100644 --- a/internal/infrastructure/auth/repository.go +++ b/internal/infrastructure/auth/repository.go @@ -68,3 +68,8 @@ func (r *repository) Login(email *string) (*string, error) { func (r *repository) AddNumberFailedAttempts(userID *string) error { return r.pg.addNumberFailedAttempts(userID) } + +// LoginSteps contains the flow to get the data needed to retrieve the steps required to log in a user +func (r *repository) LoginSteps(email *string) (*auth.Steps, error) { + return r.pg.loginSteps(email) +} diff --git a/internal/infrastructure/auth/user/otp/postgres.go b/internal/infrastructure/auth/user/otp/postgres.go new file mode 100644 index 0000000..6664108 --- /dev/null +++ b/internal/infrastructure/auth/user/otp/postgres.go @@ -0,0 +1,61 @@ +// Copyright (c) 2023 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package otp + +import ( + "database/sql" + + "github.com/Masterminds/squirrel" + "github.com/google/uuid" + + "github.com/isaqueveras/power-sso/pkg/database/postgres" + "github.com/isaqueveras/power-sso/pkg/oops" +) + +// PGOTP is the implementation of transaction for the otp repository +type PGOTP struct { + DB *postgres.DBTransaction +} + +// GetToken fetch the token of a user's otp +func (pg *PGOTP) GetToken(userID *uuid.UUID) (userName, token *string, err error) { + if err = pg.DB.Builder. + Select("CONCAT('(',first_name,' ',last_name,')') AS user_name, otp_token"). + From("public.users"). + Where("id = ?::UUID", userID). + QueryRow(). + Scan(&userName, &token); err != nil { + return nil, nil, oops.Err(err) + } + return +} + +// Configure configure otp for a user +func (pg *PGOTP) Configure(userID *uuid.UUID, secret *string) (err error) { + if _, err = pg.DB.Builder. + Update("users"). + Set("otp_token", secret). + Set("otp", true). + Set("otp_setup", true). + Set("updated_at", squirrel.Expr("NOW()")). + Where("id = ?", userID). + Exec(); err != nil && err != sql.ErrNoRows { + return oops.Err(err) + } + return +} + +// Unconfigure unconfigure otp for a user +func (pg *PGOTP) Unconfigure(userID *uuid.UUID) (err error) { + if _, err = pg.DB.Builder. + Update("users"). + Set("otp", false). + Set("updated_at", squirrel.Expr("NOW()")). + Where("id = ?", userID). + Exec(); err != nil && err != sql.ErrNoRows { + return oops.Err(err) + } + return +} diff --git a/internal/infrastructure/auth/user/otp/repository.go b/internal/infrastructure/auth/user/otp/repository.go new file mode 100644 index 0000000..1867bb6 --- /dev/null +++ b/internal/infrastructure/auth/user/otp/repository.go @@ -0,0 +1,37 @@ +// Copyright (c) 2023 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package otp + +import ( + "github.com/google/uuid" + + "github.com/isaqueveras/power-sso/internal/domain/auth/user/otp" + "github.com/isaqueveras/power-sso/pkg/database/postgres" +) + +// repository is the implementation of the otp repository +type repository struct { + pg *PGOTP +} + +// New creates a new repository +func New(transaction *postgres.DBTransaction) otp.IOTP { + return &repository{pg: &PGOTP{DB: transaction}} +} + +// GetToken return the token of a user's otp +func (r *repository) GetToken(userID *uuid.UUID) (*string, *string, error) { + return r.pg.GetToken(userID) +} + +// Configure configure otp for a user +func (r *repository) Configure(userID *uuid.UUID, secret *string) error { + return r.pg.Configure(userID, secret) +} + +// Unconfigure unconfigure otp for a user +func (r *repository) Unconfigure(userID *uuid.UUID) error { + return r.pg.Unconfigure(userID) +} diff --git a/internal/infrastructure/auth/user/postgres.go b/internal/infrastructure/auth/user/postgres.go index 0624d2e..0790e27 100644 --- a/internal/infrastructure/auth/user/postgres.go +++ b/internal/infrastructure/auth/user/postgres.go @@ -38,13 +38,6 @@ func (pg *pgUser) findByEmailUserExists(email *string) (exists bool, err error) // getUser get the user from the database func (pg *pgUser) getUser(data *user.User) (err error) { - var where squirrel.Eq - if data.ID != nil { - where = squirrel.Eq{"id": data.ID} - } else if data.Email != nil { - where = squirrel.Eq{"email": data.Email} - } - if err = pg.DB.Builder. Select(` U.id, @@ -67,17 +60,25 @@ func (pg *pgUser) getUser(data *user.User) (err error) { U.login_date, U.is_active, U.user_type, - U.number_failed_attempts >= 3 AND (U.last_failure_date + '1 hour') >= NOW() AS blocked_temporarily`). + U.number_failed_attempts >= 3 AND (U.last_failure_date + '1 hour') >= NOW() AS blocked_temporarily, + U.otp, + U.otp_token, + U.otp_setup`). From("users U"). - Where(where). + Where(squirrel.Or{ + squirrel.Eq{"id": data.ID}, + squirrel.Eq{"email": data.Email}, + }). Scan(&data.ID, &data.Email, &data.FirstName, &data.LastName, &data.Roles, &data.About, &data.Avatar, &data.PhoneNumber, &data.Address, &data.City, &data.Country, &data.Gender, &data.Postcode, &data.TokenKey, &data.Birthday, &data.CreatedAt, &data.UpdatedAt, &data.LoginDate, &data.IsActive, - &data.UserType, &data.BlockedTemporarily); err != nil { + &data.UserType, &data.BlockedTemporarily, &data.OTPEnabled, + &data.OTPToken, &data.OTPSetup); err != nil { return oops.Err(err) } - return nil + + return } // disableUser disable user in database diff --git a/internal/interface/http/auth/handler.go b/internal/interface/http/auth/handler.go index de3a295..30fc276 100644 --- a/internal/interface/http/auth/handler.go +++ b/internal/interface/http/auth/handler.go @@ -110,3 +110,25 @@ func logout(ctx *gin.Context) { ctx.JSON(http.StatusNoContent, utils.NoContent{}) } + +// loginSteps godoc +// @Summary Steps to login +// @Description Steps to login +// @Tags Http/Auth +// @Accept json +// @Produce json +// @Success 200 {object} auth.StepsResponse +// @Router /v1/auth/login/steps [get] +func loginSteps(ctx *gin.Context) { + var ( + res *auth.StepsResponse + err error + ) + + if res, err = auth.LoginSteps(ctx, utils.GetStringPointer(ctx.Query("email"))); err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusOK, res) +} diff --git a/internal/interface/http/auth/handler_test.go b/internal/interface/http/auth/handler_test.go index 083f585..ba962c9 100644 --- a/internal/interface/http/auth/handler_test.go +++ b/internal/interface/http/auth/handler_test.go @@ -7,6 +7,7 @@ package auth import ( "bytes" "context" + "database/sql" "encoding/json" "net/http" "net/http/httptest" @@ -21,17 +22,17 @@ import ( "github.com/isaqueveras/power-sso/pkg/oops" ) -func TestHandlerAuthInterface(t *testing.T) { - suite.Run(t, new(authHandlerSuite)) +func TestHandlerAuth(t *testing.T) { + suite.Run(t, new(testSuite)) } -type authHandlerSuite struct { +type testSuite struct { router *gin.Engine suite.Suite } -func (a *authHandlerSuite) SetupSuite() { +func (a *testSuite) SetupSuite() { config.LoadConfig("../../../../") a.router = gin.New() @@ -39,7 +40,7 @@ func (a *authHandlerSuite) SetupSuite() { RouterAuthorization(a.router.Group("v1/auth")) } -func (a *authHandlerSuite) TestShouldCreateUser() { +func (a *testSuite) TestShouldCreateUser() { monkey.Patch(auth.Register, func(_ context.Context, _ *auth.RegisterRequest) error { return nil }) @@ -66,3 +67,35 @@ func (a *authHandlerSuite) TestShouldCreateUser() { a.router.ServeHTTP(w, req) a.Assert().Equal(http.StatusCreated, w.Code) } + +func (t *testSuite) TestLoginSteps() { + t.Run("UserFound", func() { + monkey.Patch(auth.LoginSteps, func(ctx context.Context, email *string) (res *auth.StepsResponse, err error) { + return nil, nil + }) + defer monkey.Unpatch(auth.LoginSteps) + + var ( + req = httptest.NewRequest(http.MethodGet, "/v1/auth/login/steps?email=luiz@bonfa.com", nil) + w = httptest.NewRecorder() + ) + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusOK, w.Code) + }) + + t.Run("UserNotFound", func() { + monkey.Patch(auth.LoginSteps, func(ctx context.Context, email *string) (res *auth.StepsResponse, err error) { + return nil, sql.ErrNoRows + }) + defer monkey.Unpatch(auth.LoginSteps) + + var ( + req = httptest.NewRequest(http.MethodGet, "/v1/auth/login/steps", nil) + w = httptest.NewRecorder() + ) + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusNotFound, w.Code) + }) +} diff --git a/internal/interface/http/auth/router.go b/internal/interface/http/auth/router.go index 2dbe086..ed6f908 100644 --- a/internal/interface/http/auth/router.go +++ b/internal/interface/http/auth/router.go @@ -6,6 +6,7 @@ package auth import ( "github.com/gin-gonic/gin" + "github.com/isaqueveras/power-sso/internal/interface/http/auth/user" ) @@ -14,6 +15,7 @@ func Router(r *gin.RouterGroup) { r.POST("activation/:token", activation) r.POST("register", register) r.POST("login", login) + r.GET("login/steps", loginSteps) } // RouterAuthorization is the router for the auth module. diff --git a/internal/interface/http/auth/user/otp/handler.go b/internal/interface/http/auth/user/otp/handler.go new file mode 100644 index 0000000..a4eecbb --- /dev/null +++ b/internal/interface/http/auth/user/otp/handler.go @@ -0,0 +1,98 @@ +// Copyright (c) 2023 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package otp + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/isaqueveras/power-sso/internal/application/auth/user/otp" + "github.com/isaqueveras/power-sso/internal/utils" + "github.com/isaqueveras/power-sso/pkg/oops" +) + +// configure godoc +// @Summary Configure a user's OTP +// @Description Configure a user's OTP +// @Tags Http/Auth/OTP +// @Accept json +// @Produce json +// @Success 201 {object} utils.NoContent{} +// @Router /v1/auth/user/{user_uuid}/otp/configure [post] +func configure(ctx *gin.Context) { + var ( + err error + userID uuid.UUID + ) + + if userID, err = uuid.Parse(ctx.Param("user_uuid")); err != nil { + oops.Handling(ctx, err) + return + } + + if err = otp.Configure(ctx, &userID); err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusCreated, utils.NoContent{}) +} + +// unconfigure godoc +// @Summary unconfigure a user's OTP +// @Description unconfigure a user's OTP +// @Tags Http/Auth/OTP +// @Accept json +// @Produce json +// @Success 201 {object} utils.NoContent{} +// @Router /v1/auth/user/{user_uuid}/otp/unconfigure [put] +func unconfigure(ctx *gin.Context) { + var ( + err error + userID uuid.UUID + ) + + if userID, err = uuid.Parse(ctx.Param("user_uuid")); err != nil { + oops.Handling(ctx, err) + return + } + + if err = otp.Unconfigure(ctx, &userID); err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusCreated, utils.NoContent{}) +} + +// qrcode godoc +// @Summary Configure a user's OTP +// @Description Configure a user's OTP +// @Tags Http/Auth/OTP +// @Accept json +// @Produce json +// @Success 200 {object} otp.QRCodeResponse +// @Router /v1/auth/user/{user_uuid}/otp/qrcode [get] +func qrcode(ctx *gin.Context) { + var ( + err error + userID uuid.UUID + res *otp.QRCodeResponse + ) + + if userID, err = uuid.Parse(ctx.Param("user_uuid")); err != nil { + oops.Handling(ctx, err) + return + } + + if res, err = otp.GetQrCode(ctx, &userID); err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusOK, res) +} diff --git a/internal/interface/http/auth/user/otp/handler_test.go b/internal/interface/http/auth/user/otp/handler_test.go new file mode 100644 index 0000000..27ddd5b --- /dev/null +++ b/internal/interface/http/auth/user/otp/handler_test.go @@ -0,0 +1,142 @@ +// Copyright (c) 2022 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package otp + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "bou.ke/monkey" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/suite" + + "github.com/isaqueveras/power-sso/config" + "github.com/isaqueveras/power-sso/internal/application/auth/user/otp" + "github.com/isaqueveras/power-sso/internal/middleware" +) + +const sucessUserID = "9ec1b2a7-665c-47a7-b180-54f11f8a6122" + +func TestHandlerOTPInterface(t *testing.T) { + suite.Run(t, new(testSuite)) +} + +type testSuite struct { + router *gin.Engine + + suite.Suite +} + +func (o *testSuite) SetupSuite() { + config.LoadConfig("../../../../../../") + var handleUserLog = func() gin.HandlerFunc { + return func(ctx *gin.Context) { + ctx.Set("UID", sucessUserID) + } + } + + o.router = gin.New() + o.router.Use(middleware.RequestIdentifier(), handleUserLog()) + Router(o.router.Group("v1/auth/user/:user_uuid/otp")) +} + +func (t *testSuite) TestShouldGetUrlQrCode() { + t.Run("Success", func() { + monkey.Patch(otp.GetQrCode, func(_ context.Context, _ *uuid.UUID) (*otp.QRCodeResponse, error) { + return &otp.QRCodeResponse{}, nil + }) + defer monkey.Unpatch(otp.GetQrCode) + + var ( + req = httptest.NewRequest(http.MethodGet, "/v1/auth/user/"+sucessUserID+"/otp/qrcode", nil) + w = httptest.NewRecorder() + ) + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusOK, w.Code) + }) + + t.Run("Error::FetchAnotherUserURL", func() { + monkey.Patch(otp.GetQrCode, func(_ context.Context, _ *uuid.UUID) (*otp.QRCodeResponse, error) { + return &otp.QRCodeResponse{}, nil + }) + defer monkey.Unpatch(otp.GetQrCode) + + var ( + req = httptest.NewRequest(http.MethodGet, "/v1/auth/user/"+uuid.New().String()+"/otp/qrcode", nil) + w = httptest.NewRecorder() + ) + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusForbidden, w.Code) + }) +} + +func (t *testSuite) TestShouldConfigure() { + t.Run("Success", func() { + monkey.Patch(otp.Configure, func(_ context.Context, _ *uuid.UUID) error { + return nil + }) + defer monkey.Unpatch(otp.Configure) + + var ( + req = httptest.NewRequest(http.MethodPost, "/v1/auth/user/"+sucessUserID+"/otp/configure", nil) + w = httptest.NewRecorder() + ) + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusCreated, w.Code) + }) + + t.Run("Error::FetchAnotherUserURL", func() { + monkey.Patch(otp.Configure, func(_ context.Context, _ *uuid.UUID) error { + return nil + }) + defer monkey.Unpatch(otp.Configure) + + var ( + req = httptest.NewRequest(http.MethodPost, "/v1/auth/user/"+uuid.New().String()+"/otp/configure", nil) + w = httptest.NewRecorder() + ) + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusForbidden, w.Code) + }) +} + +func (t *testSuite) TestShouldUnconfigure() { + t.Run("Success", func() { + monkey.Patch(otp.Unconfigure, func(_ context.Context, _ *uuid.UUID) error { + return nil + }) + defer monkey.Unpatch(otp.Unconfigure) + + var ( + req = httptest.NewRequest(http.MethodPut, "/v1/auth/user/"+sucessUserID+"/otp/unconfigure", nil) + w = httptest.NewRecorder() + ) + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusCreated, w.Code) + }) + + t.Run("Error::FetchAnotherUserURL", func() { + monkey.Patch(otp.Unconfigure, func(_ context.Context, _ *uuid.UUID) error { + return nil + }) + defer monkey.Unpatch(otp.Unconfigure) + + var ( + req = httptest.NewRequest(http.MethodPut, "/v1/auth/user/"+uuid.New().String()+"/otp/unconfigure", nil) + w = httptest.NewRecorder() + ) + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusForbidden, w.Code) + }) +} diff --git a/internal/interface/http/auth/user/otp/router.go b/internal/interface/http/auth/user/otp/router.go new file mode 100644 index 0000000..8ba2c79 --- /dev/null +++ b/internal/interface/http/auth/user/otp/router.go @@ -0,0 +1,19 @@ +// Copyright (c) 2023 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package otp + +import ( + gin "github.com/gin-gonic/gin" + gopowersso "github.com/isaqueveras/go-powersso" +) + +// Router is the router for the otp module. +func Router(r *gin.RouterGroup) { + r.Use(gopowersso.SameUser()) + + r.GET("qrcode", qrcode) + r.POST("configure", configure) + r.PUT("unconfigure", unconfigure) +} diff --git a/internal/interface/http/auth/user/router.go b/internal/interface/http/auth/user/router.go index 1d360c1..9274472 100644 --- a/internal/interface/http/auth/user/router.go +++ b/internal/interface/http/auth/user/router.go @@ -4,9 +4,15 @@ package user -import "github.com/gin-gonic/gin" +import ( + "github.com/gin-gonic/gin" + + "github.com/isaqueveras/power-sso/internal/interface/http/auth/user/otp" +) // RouterWithUUID is the router for the user module. func RouterWithUUID(r *gin.RouterGroup) { r.PUT("disable", disable) + + otp.Router(r.Group("otp")) } diff --git a/internal/middleware/metric.go b/internal/middleware/metric.go index 7c9c9fd..460f796 100644 --- a/internal/middleware/metric.go +++ b/internal/middleware/metric.go @@ -49,10 +49,13 @@ func GinZap(logger *zap.Logger, cfg config.Config) gin.HandlerFunc { ) if errors.As(err, &e) { + if e.Err != nil && len(e.Err.Error()) > 0 { + fields = append(fields, []zap.Field{zap.String("cause", e.Err.Error())}...) + } + fields = append(fields, []zap.Field{ zap.Int("error_code", e.Code), zap.String("error", e.Error()), - zap.String("cause", e.Err.Error()), zap.Strings("trace", e.Trace), }...) isError = true diff --git a/migrations/20220812135531_create_table_users.up.sql b/migrations/20220812135531_create_table_users.up.sql index 02c2ecd..1e86ec0 100644 --- a/migrations/20220812135531_create_table_users.up.sql +++ b/migrations/20220812135531_create_table_users.up.sql @@ -10,13 +10,13 @@ CREATE TABLE users ( first_name VARCHAR(32) NOT NULL CHECK ( first_name <> '' ), last_name VARCHAR(32) NOT NULL CHECK ( last_name <> '' ), email VARCHAR(64) UNIQUE NOT NULL CHECK ( email <> '' ), - password VARCHAR(250) NOT NULL CHECK ( octet_length(password) <> 0 ), + password VARCHAR(150) NOT NULL CHECK ( octet_length(password) <> 0 ), roles VARCHAR[] NOT NULL DEFAULT '{}', - about VARCHAR(500) DEFAULT '', - avatar VARCHAR(512), + about VARCHAR(150), + avatar VARCHAR(200), user_type user_types NOT NULL DEFAULT 'user', phone_number VARCHAR(20), - address VARCHAR(250), + address VARCHAR(200), city VARCHAR(30), country VARCHAR(30), gender VARCHAR(20) DEFAULT '', @@ -26,6 +26,9 @@ CREATE TABLE users ( is_active BOOLEAN NOT NULL DEFAULT TRUE, number_failed_attempts INTEGER NOT NULL DEFAULT 0, last_failure_date TIMESTAMP, + otp_token TEXT, + otp BOOLEAN, + otp_setup BOOLEAN, birthday DATE DEFAULT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, diff --git a/otp/opt.go b/otp/opt.go new file mode 100644 index 0000000..bdfe0f0 --- /dev/null +++ b/otp/opt.go @@ -0,0 +1,102 @@ +// Copyright (c) 2023 Isaque Veras +// Use of this source code is governed by MIT +// license that can be found in the LICENSE file. + +package otp + +import ( + "bytes" + "crypto/hmac" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "errors" + "strconv" + "strings" + "time" + + "github.com/isaqueveras/power-sso/config" + "github.com/isaqueveras/power-sso/pkg/oops" +) + +const ( + windowSize = 5 + stepSize = 30 + + QrCodeURL = "https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl=200x200&chld=M|0&cht=qr&chl=" +) + +// ValidateToken validates if the otp is valid +func ValidateToken(token, otp *string) (err error) { + if otp == nil { + return oops.Err(errors.New("the OTP must be sent")) + } + + for _, value := range []int64{ + (time.Now().Unix() / stepSize), + (time.Now().Unix() - windowSize) / stepSize, + } { + var generated string + if generated, err = GenerateToken(*token, value); err != nil { + return oops.Err(err) + } + + if generated == *otp { + return nil + } + } + + return errors.New("invalid OTP code") +} + +// GenerateToken generate an OTP token +func GenerateToken(secret string, ts int64) (otp string, err error) { + // Converts secret to base32 Encoding. Base32 encoding desires a 32-character + // subset of the twenty-six letters A–Z and ten digits 0–9 + key, err := base32.StdEncoding.DecodeString(strings.ToUpper(secret)) + if err != nil { + return otp, err + } + + bs := make([]byte, 8) + binary.BigEndian.PutUint64(bs, uint64(ts)) + + // Signing the value using HMAC-SHA1 Algorithm + hash := hmac.New(sha1.New, key) + hash.Write(bs) + h := hash.Sum(nil) + + // We're going to use a subset of the generated hash. + // Using the last nibble (half-byte) to choose the index to start from. + // This number is always appropriate as it's maximum decimal 15, the hash will + // have the maximum index 19 (20 bytes of SHA1) and we need 4 bytes. + o := h[19] & 15 + + var header uint32 + // Get 32 bit chunk from hash starting at the o + r := bytes.NewReader(h[o : o+4]) + if err = binary.Read(r, binary.BigEndian, &header); err != nil { + return otp, err + } + + // Ignore most significant bits as per RFC 4226. + // Takes division from one million to generate a remainder less than < 7 digits + h12 := (int(header) & 0x7fffffff) % 1000000 + + // Converts number as a string + otp = strconv.Itoa(int(h12)) + + // Add left pad + if len(otp) < 6 { + for i := 0; i < 6-len(otp); i++ { + otp = "0" + otp + } + } + + return +} + +// GetUrlQrCode returns the url of qr code to configure the otp +func GetUrlQrCode(otpToken string, userName string) (url string) { + return QrCodeURL + "otpauth://totp/" + config.Get().Meta.ProjectName + " " + userName + "%3Fsecret%3D" + otpToken +} diff --git a/otp/otp_test.go b/otp/otp_test.go new file mode 100644 index 0000000..67513bf --- /dev/null +++ b/otp/otp_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2023 Isaque Veras +// Use of this source code is governed by MIT +// license that can be found in the LICENSE file. + +package otp_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + + "github.com/isaqueveras/power-sso/config" + "github.com/isaqueveras/power-sso/otp" +) + +var tokens = []string{ + "J5WGCTLVNZSG6II=", + "JEQGW3TPO4QHS33VEB3W65LMMQQGIZLDN5SGKIDUNBUXGIDDN5SGKLBAPFXXKIDDOVZGS33VOMQQ====", + "IRXSA4LVMUQHM33DYOVCAZ3PON2GCPZB", +} + +func TestOTP(t *testing.T) { + t.Run("GenerateAndValidateToken", func(t *testing.T) { + for i := range tokens { + code, err := otp.GenerateToken(tokens[i], time.Now().Unix()/30) + if err != nil { + t.Error(err) + continue + } + + if code == "" { + t.Error("Empty otp code") + } + + if err := otp.ValidateToken(&tokens[i], &code); err != nil { + t.Error(err) + continue + } + } + }) + + t.Run("GetURLQRCode", func(t *testing.T) { + config.LoadConfig("../") + + for i := range tokens { + userUUID := uuid.New() + url := otp.GetUrlQrCode(tokens[i], userUUID.String()) + urlCorrect := otp.QrCodeURL + "otpauth://totp/" + config.Get().Meta.ProjectName + " " + userUUID.String() + "%3Fsecret%3D" + tokens[i] + + if urlCorrect != url { + t.Error("url not equal") + } + } + }) +} + +func BenchmarkOTP(b *testing.B) { + b.Run("GenerateAndValidateToken", func(b *testing.B) { + for i := range tokens { + code, err := otp.GenerateToken(tokens[i], time.Now().Unix()/30) + if err != nil { + b.Error(err) + continue + } + + if err := otp.ValidateToken(&tokens[i], &code); err != nil { + b.Error(err) + continue + } + } + }) + + b.Run("GetURLQRCode", func(b *testing.B) { + config.LoadConfig("../") + + for i := range tokens { + userUUID := uuid.New() + url := otp.GetUrlQrCode(tokens[i], userUUID.String()) + urlCorrect := otp.QrCodeURL + "otpauth://totp/" + config.Get().Meta.ProjectName + " " + userUUID.String() + "%3Fsecret%3D" + tokens[i] + + if urlCorrect != url { + b.Error("url not equal") + } + } + }) +} diff --git a/pkg/oops/error_handling.go b/pkg/oops/error_handling.go index f760191..6167f9d 100644 --- a/pkg/oops/error_handling.go +++ b/pkg/oops/error_handling.go @@ -95,3 +95,12 @@ func Err(err error) error { return errors.WithStack(err) } + +// New creates and returns new normalized `Error` instance. +func New(msg string) *Error { + return &Error{ + Message: msg, + Code: defaultCode, + StatusCode: http.StatusBadRequest, + } +}