diff --git a/README.md b/README.md index 6f5cc98..1917a61 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ make build Generate API documentation ``` go install github.com/swaggo/swag/cmd/swag@latest -go get -u github.com/swaggo/http-swagger/v2 swag init --generalInfo main.go --dir ./cmd/api,./internal/handler --output docs/ --parseDependency --parseInternal ``` diff --git a/cmd/api/main.go b/cmd/api/main.go index b5e70f3..d36fe79 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -21,6 +21,7 @@ import ( "git.kundeng.us/phoenix/textsender-auth/internal/handler" "git.kundeng.us/phoenix/textsender-auth/internal/handler/endpoint" mdleware "git.kundeng.us/phoenix/textsender-auth/internal/middleware" + "git.kundeng.us/phoenix/textsender-auth/internal/services" "git.kundeng.us/phoenix/textsender-auth/internal/store" ) @@ -82,6 +83,7 @@ func main() { refreshHandler := handler.NewRefreshHandler(cfg, userStore, serviceStore) router := chi.NewRouter() + jwtService := services.NewJWTService(config.GetSecretKey()) // Configure CORS router.Use(cors.Handler(cors.Options{ @@ -102,6 +104,7 @@ func main() { router.Method("Post", endpoint.CreateServiceUser, http.HandlerFunc(serviceHandler.Register)) router.Method("Post", endpoint.LoginServiceUser, http.HandlerFunc(serviceHandler.Login)) router.Method("Post", endpoint.TokenRefresh, http.HandlerFunc(refreshHandler.Refresh)) + router.Method("PATCH", endpoint.UpdatePassword, mdleware.AuthMiddleware(jwtService)(http.HandlerFunc(loginHandler.UpdatePassword))) router.Method("GET", "/swagger/*", httpSwagger.Handler( httpSwagger.URL(fmt.Sprintf("http://localhost:%s/swagger/doc.json", config.Port)), diff --git a/cmd/api/main_test.go b/cmd/api/main_test.go index 4c5cf77..47b1172 100644 --- a/cmd/api/main_test.go +++ b/cmd/api/main_test.go @@ -4,21 +4,24 @@ import ( "context" "flag" "fmt" + "net/http" "os" "path" "testing" - "github.com/gorilla/mux" + "github.com/go-chi/chi/v5" "github.com/joho/godotenv" "git.kundeng.us/phoenix/textsender-auth/internal/config" "git.kundeng.us/phoenix/textsender-auth/internal/db" "git.kundeng.us/phoenix/textsender-auth/internal/handler" "git.kundeng.us/phoenix/textsender-auth/internal/handler/endpoint" + mdleware "git.kundeng.us/phoenix/textsender-auth/internal/middleware" + "git.kundeng.us/phoenix/textsender-auth/internal/services" "git.kundeng.us/phoenix/textsender-auth/internal/store" ) -var testRouter *mux.Router +var testRouter *chi.Mux func TestMain(m *testing.M) { cfg := load() @@ -44,12 +47,14 @@ func TestMain(m *testing.M) { serviceHandler := handler.NewServiceHandler(cfg, serviceStore) refreshHandler := handler.NewRefreshHandler(cfg, userStore, serviceStore) - testRouter = mux.NewRouter() - testRouter.HandleFunc(endpoint.Register, userHandler.Register).Methods("POST") - testRouter.HandleFunc(endpoint.Login, loginHandler.Login).Methods("POST") - testRouter.HandleFunc(endpoint.CreateServiceUser, serviceHandler.Register).Methods("POST") - testRouter.HandleFunc(endpoint.LoginServiceUser, serviceHandler.Login).Methods("POST") - testRouter.HandleFunc(endpoint.TokenRefresh, refreshHandler.Refresh).Methods("POST") + testRouter = chi.NewRouter() + jwtService := services.NewJWTService(config.GetSecretKey()) + testRouter.Method("POST", endpoint.Register, http.HandlerFunc(userHandler.Register)) + testRouter.Method("POST", endpoint.Login, http.HandlerFunc(loginHandler.Login)) + testRouter.Method("POST", endpoint.CreateServiceUser, http.HandlerFunc(serviceHandler.Register)) + testRouter.Method("POST", endpoint.LoginServiceUser, http.HandlerFunc(serviceHandler.Login)) + testRouter.Method("POST", endpoint.TokenRefresh, http.HandlerFunc(refreshHandler.Refresh)) + testRouter.Method("PATCH", endpoint.UpdatePassword, mdleware.AuthMiddleware(jwtService)(http.HandlerFunc(loginHandler.UpdatePassword))) code := m.Run() os.Exit(code) diff --git a/docs/docs.go b/docs/docs.go index 2b07b8c..80841ca 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -275,9 +275,89 @@ const docTemplate = `{ } } } + }, + "/user/password/update": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update the password of a regular account (requires JWT)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update Password", + "parameters": [ + { + "description": "Needed data to update password", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UpdatePasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.UpdatePasswordResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.UpdatePasswordResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.UpdatePasswordResponse" + } + } + } + } } }, "definitions": { + "git_kundeng_us_phoenix_textsender-models_tx0_user.User": { + "type": "object", + "properties": { + "date_created": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "last_login": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone_number": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "handler.LoginAccount": { "type": "object", "properties": { @@ -417,6 +497,37 @@ const docTemplate = `{ } } }, + "handler.UpdatePasswordRequest": { + "type": "object", + "properties": { + "confirmed_password": { + "type": "string" + }, + "current_password": { + "type": "string" + }, + "updated_password": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "handler.UpdatePasswordResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/git_kundeng_us_phoenix_textsender-models_tx0_user.User" + } + }, + "message": { + "type": "string" + } + } + }, "token.Login": { "type": "object", "properties": { @@ -428,6 +539,9 @@ const docTemplate = `{ }, "token_type": { "type": "string" + }, + "user_id": { + "type": "string" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 5232e41..ffc891c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -269,9 +269,89 @@ } } } + }, + "/user/password/update": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update the password of a regular account (requires JWT)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update Password", + "parameters": [ + { + "description": "Needed data to update password", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UpdatePasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.UpdatePasswordResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.UpdatePasswordResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.UpdatePasswordResponse" + } + } + } + } } }, "definitions": { + "git_kundeng_us_phoenix_textsender-models_tx0_user.User": { + "type": "object", + "properties": { + "date_created": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "last_login": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone_number": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "handler.LoginAccount": { "type": "object", "properties": { @@ -411,6 +491,37 @@ } } }, + "handler.UpdatePasswordRequest": { + "type": "object", + "properties": { + "confirmed_password": { + "type": "string" + }, + "current_password": { + "type": "string" + }, + "updated_password": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "handler.UpdatePasswordResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/git_kundeng_us_phoenix_textsender-models_tx0_user.User" + } + }, + "message": { + "type": "string" + } + } + }, "token.Login": { "type": "object", "properties": { @@ -422,6 +533,9 @@ }, "token_type": { "type": "string" + }, + "user_id": { + "type": "string" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 84952c5..4954593 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,24 @@ basePath: /api/v1 definitions: + git_kundeng_us_phoenix_textsender-models_tx0_user.User: + properties: + date_created: + type: string + first_name: + type: string + id: + type: string + last_login: + type: string + last_name: + type: string + password: + type: string + phone_number: + type: string + username: + type: string + type: object handler.LoginAccount: properties: password: @@ -89,6 +108,26 @@ definitions: message: type: string type: object + handler.UpdatePasswordRequest: + properties: + confirmed_password: + type: string + current_password: + type: string + updated_password: + type: string + user_id: + type: string + type: object + handler.UpdatePasswordResponse: + properties: + data: + items: + $ref: '#/definitions/git_kundeng_us_phoenix_textsender-models_tx0_user.User' + type: array + message: + type: string + type: object token.Login: properties: access_token: @@ -97,6 +136,8 @@ definitions: type: integer token_type: type: string + user_id: + type: string type: object user.ServiceUser: properties: @@ -280,6 +321,38 @@ paths: summary: Obtain a refresh token tags: - refresh + /user/password/update: + patch: + consumes: + - application/json + description: Update the password of a regular account (requires JWT) + parameters: + - description: Needed data to update password + in: body + name: request + required: true + schema: + $ref: '#/definitions/handler.UpdatePasswordRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.UpdatePasswordResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handler.UpdatePasswordResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.UpdatePasswordResponse' + security: + - BearerAuth: [] + summary: Update Password + tags: + - users securityDefinitions: BearerAuth: description: JWT Bearer Token diff --git a/go.mod b/go.mod index 6a732d6..2cfc923 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/go-chi/cors v1.2.2 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 - github.com/gorilla/mux v1.8.1 github.com/jackc/pgx/v5 v5.7.5 github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index f828db4..c5407c6 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,6 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/internal/handler/endpoint/endpoint.go b/internal/handler/endpoint/endpoint.go index 37f2bdf..5b3b3b0 100644 --- a/internal/handler/endpoint/endpoint.go +++ b/internal/handler/endpoint/endpoint.go @@ -6,3 +6,4 @@ const Login = "/api/v1/login" const CreateServiceUser = "/api/v1/service/register" const LoginServiceUser = "/api/v1/service/login" const TokenRefresh = "/api/v1/token/refresh" +const UpdatePassword = "/api/v1/user/password/update" diff --git a/internal/handler/login.go b/internal/handler/login.go index db0da5d..460fece 100644 --- a/internal/handler/login.go +++ b/internal/handler/login.go @@ -1,16 +1,27 @@ package handler import ( - "fmt" + "log" "net/http" "git.kundeng.us/phoenix/textsender-models/tx0/token" + "git.kundeng.us/phoenix/textsender-models/tx0/user" + "github.com/google/uuid" "git.kundeng.us/phoenix/textsender-auth/internal/config" "git.kundeng.us/phoenix/textsender-auth/internal/store" "git.kundeng.us/phoenix/textsender-auth/internal/utility" ) +type LoginHandler struct { + Config *config.Config + UserStore store.UserStore +} + +func NewLoginHandler(cfg *config.Config, userStore store.UserStore) *LoginHandler { + return &LoginHandler{Config: cfg, UserStore: userStore} +} + type LoginAccount struct { Username string `json:"username"` Password string `json:"password"` @@ -21,15 +32,6 @@ type LoginResponse struct { Data []token.Login `json:"data"` } -type LoginHandler struct { - Config *config.Config - UserStore store.UserStore -} - -func NewLoginHandler(cfg *config.Config, userStore store.UserStore) *LoginHandler { - return &LoginHandler{Config: cfg, UserStore: userStore} -} - // Login godoc // @Summary Login // @Description Login and be given an access token (requires JWT) @@ -55,7 +57,7 @@ func (l *LoginHandler) Login(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if exists, err := l.UserStore.UserExists(ctx, req.Username); err != nil { - fmt.Printf("Error: %v", err) + log.Println("Error:", err) statusCode = http.StatusInternalServerError resp.Message = err.Error() } else { @@ -67,23 +69,28 @@ func (l *LoginHandler) Login(w http.ResponseWriter, r *http.Request) { statusCode = http.StatusInternalServerError resp.Message = err.Error() } else { - hashing := utility.HashMash{Password: req.Password} - if hashing.CheckPasswordHash(req.Password, user.Password) { - var tokGen utility.TokenGenerator - secretKey := config.GetSecretKey() - tokGen.SetSecretKey(secretKey) - if myToken, err := tokGen.GenerateToken(*user); err != nil { - fmt.Println(err.Error()) - statusCode = http.StatusInternalServerError - resp.Message = "Error generating token" - } else { - statusCode = http.StatusOK - resp.Data = append(resp.Data, *myToken) - resp.Message = "Successful" - } + hashing := utility.HashMash{} + if err := hashing.SetPassword(req.Password); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() } else { - statusCode = http.StatusNotFound - resp.Message = "User not found" + if hashing.CheckPasswordHash(req.Password, user.Password) { + var tokGen utility.TokenGenerator + secretKey := config.GetSecretKey() + tokGen.SetSecretKey(secretKey) + if myToken, err := tokGen.GenerateToken(*user); err != nil { + log.Println(err.Error()) + statusCode = http.StatusInternalServerError + resp.Message = "Error generating token" + } else { + statusCode = http.StatusOK + resp.Data = append(resp.Data, *myToken) + resp.Message = "Successful" + } + } else { + statusCode = http.StatusNotFound + resp.Message = "User not found" + } } } } @@ -91,3 +98,85 @@ func (l *LoginHandler) Login(w http.ResponseWriter, r *http.Request) { RespondWithJson(w, statusCode, &resp) } + +type UpdatePasswordRequest struct { + UserId uuid.UUID `json:"user_id"` + CurrentPassword string `json:"current_password"` + UpdatedPassword string `json:"updated_password"` + ConfirmedPassword string `json:"confirmed_password"` +} + +type UpdatePasswordResponse struct { + Message string `json:"message"` + Data []user.User `json:"data"` +} + +// UpdatePassword godoc +// @Summary Update Password +// @Description Update the password of a regular account (requires JWT) +// @Tags users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body UpdatePasswordRequest true "Needed data to update password" +// @Success 200 {object} UpdatePasswordResponse +// @Failure 400 {object} UpdatePasswordResponse +// @Failure 500 {object} UpdatePasswordResponse +// @Router /user/password/update [patch] +func (l *LoginHandler) UpdatePassword(w http.ResponseWriter, r *http.Request) { + var req UpdatePasswordRequest + if err := ExtractFromRequest(r, &req); err != nil { + http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) + } + defer r.Body.Close() + + var statusCode int + var resp UpdatePasswordResponse + + ctx := r.Context() + + if usr, err := l.UserStore.GetUserByID(ctx, req.UserId); err != nil { + log.Println("Error:", err) + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + hashing := utility.HashMash{} + if err := hashing.SetPassword(req.CurrentPassword); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + if hashing.CheckPasswordHash(req.CurrentPassword, usr.Password) { + if req.UpdatedPassword == req.ConfirmedPassword { + // Hash password + err := hashing.SetPassword(req.UpdatedPassword) + hashedPassword, err := hashing.HashPassword() + if err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + // Update user password + usr.Password = hashedPassword + // Save user in DB + if rowsAffected, err := l.UserStore.UpdatePassword(ctx, usr.Id, usr.Password); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + log.Println("Rows affected:", rowsAffected) + statusCode = http.StatusOK + resp.Message = "Successful" + resp.Data = append(resp.Data, *usr) + } + } + } else { + statusCode = http.StatusBadRequest + resp.Message = "Passwords do not match" + } + } else { + statusCode = http.StatusBadRequest + resp.Message = "User not found" + } + } + } + + RespondWithJson(w, statusCode, &resp) +} diff --git a/internal/handler/login_test.go b/internal/handler/login_test.go index 5587f8b..bfd9f83 100644 --- a/internal/handler/login_test.go +++ b/internal/handler/login_test.go @@ -13,7 +13,6 @@ import ( "git.kundeng.us/phoenix/textsender-auth/internal/handler/endpoint" "git.kundeng.us/phoenix/textsender-auth/internal/store/mock" - "git.kundeng.us/phoenix/textsender-auth/internal/utility" ) func TestLogin(t *testing.T) { @@ -21,20 +20,13 @@ func TestLogin(t *testing.T) { mockstore := mock.NewMockUserStore() handler := NewLoginHandler(cfg, mockstore) - testUser := GetTestUser() - unhashedPassword := testUser.Password - hashing := utility.HashMash{Password: testUser.Password} - hashedPassword, err := hashing.HashPassword() - assert.NoError(t, err) - - testUser.Password = hashedPassword - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - mockstore.CreateUser(ctx, &testUser) + testUser, unhashedPassword, err := createUser(ctx, mockstore) + assert.NoError(t, err, "Error Creating user") - loginUser := LoginAccount{Username: testUser.Username, Password: unhashedPassword} + loginUser := LoginAccount{Username: testUser.Username, Password: *unhashedPassword} jsonValue, _ := json.Marshal(loginUser) req, _ := http.NewRequest("POST", endpoint.Login, strings.NewReader(string(jsonValue))) @@ -50,3 +42,29 @@ func TestLogin(t *testing.T) { assert.NotEmpty(t, response.Data, "An access token should have been returned") } + +func TestUpdatePassword(t *testing.T) { + cfg := GetConfig() + mockstore := mock.NewMockUserStore() + handler := NewLoginHandler(cfg, mockstore) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + testUser, unhashedPassword, err := createUser(ctx, mockstore) + assert.NoError(t, err, "Error Creating user") + assert.NotNil(t, testUser, "User should not be nil") + + updatedPassword := "TakeATrip2yonder!" + newPassword := UpdatePasswordRequest{UserId: testUser.Id, CurrentPassword: *unhashedPassword, UpdatedPassword: updatedPassword, ConfirmedPassword: updatedPassword} + jsonValue, _ := json.Marshal(newPassword) + + req, _ := http.NewRequest("PATCH", endpoint.UpdatePassword, strings.NewReader(string(jsonValue))) + rr := httptest.NewRecorder() + + handler.UpdatePassword(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response UpdatePasswordResponse + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) +} diff --git a/internal/handler/refresh_test.go b/internal/handler/refresh_test.go index c14fe0c..091a5d8 100644 --- a/internal/handler/refresh_test.go +++ b/internal/handler/refresh_test.go @@ -19,8 +19,12 @@ func TestRefreshTokenWithMock(t *testing.T) { var serviceUser user.ServiceUser var hashedPassword string var err error - unhashed := "9328nr29nudx3292m320!" - hashing := utility.HashMash{Password: unhashed} + unhashed := "A9328nr29nudx3292m320!" + hashing := utility.HashMash{} + if err := hashing.SetPassword(unhashed); err != nil { + assert.NoError(t, err, "Error setting password") + } + if hashedPassword, err = hashing.HashPassword(); err != nil { assert.NoError(t, err, "Error hashing password: %v", err) } else { diff --git a/internal/handler/register.go b/internal/handler/register.go index 1f2d89e..e5a7dbc 100644 --- a/internal/handler/register.go +++ b/internal/handler/register.go @@ -85,20 +85,25 @@ func (u *UserHandler) Register(w http.ResponseWriter, r *http.Request) { statusCode = http.StatusBadRequest resp.Message = "Failure in creating User" } else { - hashing := utility.HashMash{Password: user.Password} - if hashedPassword, err := hashing.HashPassword(); err != nil { + hashing := utility.HashMash{} + if err := hashing.SetPassword(req.Password); err != nil { statusCode = http.StatusInternalServerError resp.Message = err.Error() } else { - user.Password = hashedPassword - err := u.UserStore.CreateUser(ctx, &user) - if err != nil { + if hashedPassword, err := hashing.HashPassword(); err != nil { statusCode = http.StatusInternalServerError resp.Message = err.Error() } else { - resp.Message = "Successful" - statusCode = http.StatusOK - resp.Data = append(resp.Data, RegisterResponseItem{Id: user.Id, PhoneNumber: user.PhoneNumber, Username: user.Username}) + user.Password = hashedPassword + err := u.UserStore.CreateUser(ctx, &user) + if err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + resp.Message = "Successful" + statusCode = http.StatusOK + resp.Data = append(resp.Data, RegisterResponseItem{Id: user.Id, PhoneNumber: user.PhoneNumber, Username: user.Username}) + } } } } diff --git a/internal/handler/service.go b/internal/handler/service.go index 630db6d..09e5770 100644 --- a/internal/handler/service.go +++ b/internal/handler/service.go @@ -70,19 +70,24 @@ func (s *ServiceHandler) Register(w http.ResponseWriter, r *http.Request) { statusCode = http.StatusBadRequest resp.Message = "Service user already exists" } else { - hashing := utility.HashMash{Password: req.Passphrase} - if hashedPassword, err := hashing.HashPassword(); err != nil { + hashing := utility.HashMash{} + if err := hashing.SetPassword(req.Passphrase); err != nil { statusCode = http.StatusInternalServerError resp.Message = err.Error() } else { - serviceUser := user.ServiceUser{Username: req.Username, Passphrase: hashedPassword} - if err := s.ServiceStore.Create(ctx, &serviceUser); err != nil { + if hashedPassword, err := hashing.HashPassword(); err != nil { statusCode = http.StatusInternalServerError resp.Message = err.Error() } else { - statusCode = http.StatusCreated - resp.Message = "Successful" - resp.Data = append(resp.Data, &serviceUser) + serviceUser := user.ServiceUser{Username: req.Username, Passphrase: hashedPassword} + if err := s.ServiceStore.Create(ctx, &serviceUser); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + statusCode = http.StatusCreated + resp.Message = "Successful" + resp.Data = append(resp.Data, &serviceUser) + } } } } @@ -139,23 +144,28 @@ func (s *ServiceHandler) Login(w http.ResponseWriter, r *http.Request) { statusCode = http.StatusNotFound resp.Message = "Not found" } else { - hashing := utility.HashMash{Password: req.Passphrase} - if !hashing.CheckPasswordHash(req.Passphrase, serviceUser.Passphrase) { + hashing := utility.HashMash{} + if err := hashing.SetPassword(req.Passphrase); err != nil { statusCode = http.StatusInternalServerError - resp.Message = "Not valid" + resp.Message = err.Error() } else { - var tokGen utility.TokenGenerator - tokGen.SetHourOffset(8) - secretKey := config.GetSecretKey() - tokGen.SetSecretKey(secretKey) - - if myToken, err := tokGen.GenerateToken(*serviceUser); err != nil { + if !hashing.CheckPasswordHash(req.Passphrase, serviceUser.Passphrase) { statusCode = http.StatusInternalServerError - resp.Message = err.Error() + resp.Message = "Not valid" } else { - statusCode = http.StatusOK - resp.Data = append(resp.Data, myToken) - resp.Message = "Successful" + var tokGen utility.TokenGenerator + tokGen.SetHourOffset(8) + secretKey := config.GetSecretKey() + tokGen.SetSecretKey(secretKey) + + if myToken, err := tokGen.GenerateToken(*serviceUser); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + statusCode = http.StatusOK + resp.Data = append(resp.Data, myToken) + resp.Message = "Successful" + } } } } diff --git a/internal/handler/service_test.go b/internal/handler/service_test.go index 3063f1f..4fbc78d 100644 --- a/internal/handler/service_test.go +++ b/internal/handler/service_test.go @@ -20,7 +20,7 @@ func TestCreateServiceUserWithMock(t *testing.T) { mockStore := mock.NewMockServiceUserStore() handler := NewServiceHandler(cfg, mockStore) - testService := ServiceCreationRequest{Username: "swoon", Passphrase: "ewrewr329n12y3x2!2"} + testService := ServiceCreationRequest{Username: "swoon", Passphrase: "Ewrewr329n12y3x2!2"} jsonValue, err := json.Marshal(testService) assert.NoError(t, err, "Error marshaling request") @@ -39,8 +39,12 @@ func TestLoginServiceUserWithMock(t *testing.T) { var serviceUser user.ServiceUser var hashedPassword string var err error - unhashed := "9328nr29nudx3292m320!" - hashing := utility.HashMash{Password: unhashed} + unhashed := "A9328nr29nudx3292m320!" + hashing := utility.HashMash{} + if err := hashing.SetPassword(unhashed); err != nil { + assert.NoError(t, err, "Error setting password") + } + if hashedPassword, err = hashing.HashPassword(); err != nil { assert.NoError(t, err, "Error hashing password: %v", err) } else { diff --git a/internal/handler/utility_test.go b/internal/handler/utility_test.go index 6b8b695..9e2885b 100644 --- a/internal/handler/utility_test.go +++ b/internal/handler/utility_test.go @@ -1,6 +1,8 @@ package handler import ( + "context" + "fmt" "log" "os" "path" @@ -9,10 +11,12 @@ import ( "github.com/joho/godotenv" "git.kundeng.us/phoenix/textsender-auth/internal/config" + "git.kundeng.us/phoenix/textsender-auth/internal/store/mock" + "git.kundeng.us/phoenix/textsender-auth/internal/utility" ) func GetTestUser() user.User { - return user.User{Username: "ghost", PhoneNumber: "+1234567890", Password: "dfgdffddfd"} + return user.User{Username: "ghost", PhoneNumber: "+1234567890", Password: "Dfgdffd343dfd!"} } func GetConfig() *config.Config { @@ -38,3 +42,22 @@ func GetConfig() *config.Config { EnableRegistration: config.CheckRegistration(), } } + +func createUser(ctx context.Context, userStore *mock.MockUserStore) (*user.User, *string, error) { + testUser := GetTestUser() + unhashedPassword := testUser.Password + hashing := utility.HashMash{} + if err := hashing.SetPassword(testUser.Password); err != nil { + return nil, nil, fmt.Errorf("Error setting password: %v", err) + } + + hashedPassword, err := hashing.HashPassword() + if err != nil { + return nil, nil, err + } + + testUser.Password = hashedPassword + userStore.CreateUser(ctx, &testUser) + + return &testUser, &unhashedPassword, nil +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..137938d --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "git.kundeng.us/phoenix/textsender-auth/internal/services" +) + +type contextKey string + +const ( + UserContextKey contextKey = "user" +) + +func AuthMiddleware(authService *services.JWTService) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Authorization header required", http.StatusUnauthorized) + return + } + + // Extract token from "Bearer " + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + http.Error(w, "Invalid authorization header format", http.StatusUnauthorized) + return + } + + token := parts[1] + + // Validate token with auth service + user, err := authService.ValidateToken(token) + if err != nil { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + // Add user to context + ctx := context.WithValue(r.Context(), UserContextKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/services/jwt_service.go b/internal/services/jwt_service.go new file mode 100644 index 0000000..ee38e90 --- /dev/null +++ b/internal/services/jwt_service.go @@ -0,0 +1,49 @@ +package services + +import ( + "time" + + txtmodels_token "git.kundeng.us/phoenix/textsender-models/tx0/token" + txtmodels_user "git.kundeng.us/phoenix/textsender-models/tx0/user" + "github.com/golang-jwt/jwt/v5" +) + +type JWTService struct { + secretKey []byte +} + +func NewJWTService(secretKey string) *JWTService { + return &JWTService{ + secretKey: []byte(secretKey), + } +} + +func (s *JWTService) ValidateToken(tokenString string) (*txtmodels_user.User, error) { + // TODO: Include more user information in the claims to populate user + claims := &txtmodels_token.Claims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + // Validate the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return s.secretKey, nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, jwt.ErrSignatureInvalid + } + + // Check token expiration + if time.Now().After(claims.ExpiresAt.Time) { + return nil, jwt.ErrTokenExpired + } + + return &txtmodels_user.User{ + Id: claims.UserId, + }, nil +} diff --git a/internal/store/mock/store.go b/internal/store/mock/user_store.go similarity index 81% rename from internal/store/mock/store.go rename to internal/store/mock/user_store.go index bc7e0fc..3164eb1 100644 --- a/internal/store/mock/store.go +++ b/internal/store/mock/user_store.go @@ -4,6 +4,7 @@ import ( "context" "errors" "sync" + "time" "github.com/google/uuid" @@ -37,9 +38,11 @@ func (m *MockUserStore) CreateUser(ctx context.Context, user *user.User) error { } if _, exists := m.UsersByUsername[user.Username]; exists { - return errors.New("User with email already exists") + return errors.New("User with username already exists") } + user.Created = time.Now() + m.Users[user.Id] = user m.UsersByUsername[user.Username] = user return nil @@ -112,3 +115,24 @@ func (m *MockUserStore) UserExists(ctx context.Context, username string) (bool, return exists, nil } } + +func (m *MockUserStore) UpdatePassword(ctx context.Context, id uuid.UUID, password string) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.Error != nil { + return 0, m.Error + } + + user, exists := m.Users[id] + if !exists { + return 0, errors.New("User not found") + } + + user.Password = password + + m.Users[id] = user + m.UsersByUsername[user.Username] = user + + return 1, nil +} diff --git a/internal/store/user.go b/internal/store/user.go index c838503..ecba5e5 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -17,6 +17,7 @@ type UserStore interface { GetUserByUsername(ctx context.Context, username string) (*user.User, error) GetAllUsers(ctx context.Context) ([]*user.User, error) UserExists(ctx context.Context, username string) (bool, error) + UpdatePassword(ctx context.Context, id uuid.UUID, password string) (int64, error) } type PGUserStore struct { @@ -31,20 +32,20 @@ func (s *PGUserStore) CreateUser(ctx context.Context, user *user.User) error { query := ` INSERT INTO users (phone_number, username, password) VALUES ($1, $2, $3) - RETURNING id, phone_number, username + RETURNING id, phone_number, username, created ` return s.db.QueryRow(ctx, query, user.PhoneNumber, user.Username, user.Password).Scan( - &user.Id, &user.PhoneNumber, &user.Username, + &user.Id, &user.PhoneNumber, &user.Username, &user.Created, ) } func (s *PGUserStore) GetUserByID(ctx context.Context, id uuid.UUID) (*user.User, error) { - query := `SELECT id, username, password, phone_number FROM users WHERE id = $1` + query := `SELECT id, username, password, phone_number, created FROM users WHERE id = $1` var user user.User err := s.db.QueryRow(ctx, query, id).Scan( - &user.Id, &user.Username, &user.Password, &user.PhoneNumber, + &user.Id, &user.Username, &user.Password, &user.PhoneNumber, &user.Created, ) if err == pgx.ErrNoRows { @@ -58,11 +59,11 @@ func (s *PGUserStore) GetUserByID(ctx context.Context, id uuid.UUID) (*user.User } func (s *PGUserStore) GetUserByUsername(ctx context.Context, username string) (*user.User, error) { - query := `SELECT id, username, password, phone_number FROM users WHERE username = $1` + query := `SELECT id, username, password, phone_number, created FROM users WHERE username = $1` var user user.User err := s.db.QueryRow(ctx, query, username).Scan( - &user.Id, &user.Username, &user.Password, &user.PhoneNumber, + &user.Id, &user.Username, &user.Password, &user.PhoneNumber, &user.Created, ) if err == pgx.ErrNoRows { @@ -76,7 +77,7 @@ func (s *PGUserStore) GetUserByUsername(ctx context.Context, username string) (* } func (s *PGUserStore) GetAllUsers(ctx context.Context) ([]*user.User, error) { - query := `SELECT id, username, password, phone_number FROM users` + query := `SELECT id, username, password, phone_number, created FROM users` rows, err := s.db.Query(ctx, query) if err != nil { @@ -88,7 +89,7 @@ func (s *PGUserStore) GetAllUsers(ctx context.Context) ([]*user.User, error) { for rows.Next() { var user user.User if err := rows.Scan( - &user.Id, &user.Username, &user.Password, &user.PhoneNumber, + &user.Id, &user.Username, &user.Password, &user.PhoneNumber, &user.Created, ); err != nil { return nil, fmt.Errorf("scanning user row: %w", err) } @@ -113,3 +114,12 @@ func (s *PGUserStore) UserExists(ctx context.Context, username string) (bool, er return exists, nil } + +func (s *PGUserStore) UpdatePassword(ctx context.Context, id uuid.UUID, password string) (int64, error) { + query := `UPDATE users SET password = $1 WHERE id = $2` + if affected, err := s.db.Exec(ctx, query, password, id); err != nil { + return 0, err + } else { + return affected.RowsAffected(), nil + } +} diff --git a/internal/utility/hashing.go b/internal/utility/hashing.go index 2f42ed9..1b4b64b 100644 --- a/internal/utility/hashing.go +++ b/internal/utility/hashing.go @@ -1,15 +1,19 @@ package utility import ( + "fmt" + "strings" + "unicode" + "golang.org/x/crypto/bcrypt" ) type HashMash struct { - Password string + password string } func (h *HashMash) HashPassword() (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(h.Password), bcrypt.DefaultCost) + bytes, err := bcrypt.GenerateFromPassword([]byte(h.password), bcrypt.DefaultCost) return string(bytes), err } @@ -18,6 +22,41 @@ func (h *HashMash) CheckPasswordHash(password string, hash string) bool { return err == nil } -func (h *HashMash) SetPassword(password string) { - h.Password = password +func (h *HashMash) SetPassword(password string) error { + if len(password) < 8 { + return fmt.Errorf("Password length is not enought") + } else if len(password) > 32 { + return fmt.Errorf("Password length is too long") + } else { + specialCharacters := "!@#$%^&*?" + numbers := "0123456789" + if strings.ContainsAny(password, specialCharacters) && strings.ContainsAny(password, numbers) { + var hasAtleastOneUpper, hasAtleastOneLower bool + + for _, c := range password { + if unicode.IsUpper(c) { + hasAtleastOneUpper = true + } else if unicode.IsLower(c) { + hasAtleastOneLower = true + } + + if hasAtleastOneLower && hasAtleastOneUpper { + break + } + } + + if hasAtleastOneUpper && hasAtleastOneLower { + h.password = password + return nil + } else { + if !hasAtleastOneUpper { + return fmt.Errorf("Password requires at least one upper case letter") + } else { + return fmt.Errorf("Password requires at least one lower case letter") + } + } + } else { + return fmt.Errorf("Password should contain special characters and numbers") + } + } } diff --git a/migrations/schema.sql b/migrations/schema.sql index acfd1c1..d6e84ca 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -7,7 +7,8 @@ CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), phone_number TEXT NOT NULL, username TEXT NOT NULL, - password TEXT NOT NULL + password TEXT NOT NULL, + created timestamptz DEFAULT now() ); CREATE TABLE service_users (