diff --git a/internal/api/handlers.go b/internal/api/handlers.go index d5f2d80..77f7591 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -9,7 +9,9 @@ import ( "io/fs" "log" "math/big" + mrand "math/rand" "net/http" + "regexp" "strconv" "strings" "time" @@ -38,6 +40,7 @@ func NewGoCertRouter(env *Environment) http.Handler { apiV1Router.HandleFunc("GET /accounts", GetUserAccounts(env)) apiV1Router.HandleFunc("POST /accounts", PostUserAccount(env)) apiV1Router.HandleFunc("DELETE /accounts/{id}", DeleteUserAccount(env)) + apiV1Router.HandleFunc("POST /accounts/{id}/change_password", ChangeUserAccountPassword(env)) apiV1Router.HandleFunc("POST /login", Login(env)) @@ -327,13 +330,21 @@ func PostUserAccount(env *Environment) http.HandlerFunc { return } if user.Password == "" { - generatedPassword, err := GeneratePassword(8) + generatedPassword, err := generatePassword() if err != nil { logErrorAndWriteResponse("Failed to generate password", http.StatusInternalServerError, w) return } user.Password = generatedPassword } + if !validatePassword(user.Password) { + logErrorAndWriteResponse( + "Password does not meet requirements. It must include at least one capital letter, one lowercase letter, and either a number or a symbol.", + http.StatusBadRequest, + w, + ) + return + } users, err := env.DB.RetrieveAllUsers() if err != nil { logErrorAndWriteResponse("Failed to retrieve users: "+err.Error(), http.StatusInternalServerError, w) @@ -387,6 +398,42 @@ func DeleteUserAccount(env *Environment) http.HandlerFunc { } } +func ChangeUserAccountPassword(env *Environment) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var user certdb.User + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + logErrorAndWriteResponse("Invalid JSON format", http.StatusBadRequest, w) + return + } + if user.Password == "" { + logErrorAndWriteResponse("Password is required", http.StatusBadRequest, w) + return + } + if !validatePassword(user.Password) { + logErrorAndWriteResponse( + "Password does not meet requirements. It must include at least one capital letter, one lowercase letter, and either a number or a symbol.", + http.StatusBadRequest, + w, + ) + return + } + ret, err := env.DB.UpdateUser(id, user.Password) + if err != nil { + if errors.Is(err, certdb.ErrIdNotFound) { + logErrorAndWriteResponse(err.Error(), http.StatusNotFound, w) + return + } + logErrorAndWriteResponse(err.Error(), http.StatusInternalServerError, w) + return + } + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(strconv.FormatInt(ret, 10))); err != nil { + logErrorAndWriteResponse(err.Error(), http.StatusInternalServerError, w) + } + } +} + func Login(env *Environment) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var userRequest certdb.User @@ -438,17 +485,64 @@ func logErrorAndWriteResponse(msg string, status int, w http.ResponseWriter) { } } -var GeneratePassword = func(length int) (string, error) { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789&*?@" - b := make([]byte, length) - for i := range b { +func getRandomChars(charset string, length int) (string, error) { + result := make([]byte, length) + for i := range result { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) if err != nil { return "", err } - b[i] = charset[n.Int64()] + result[i] = charset[n.Int64()] } - return string(b), nil + return string(result), nil +} + +// Generates a random 16 chars long password that contains uppercase and lowercase characters and numbers or symbols. +func generatePassword() (string, error) { + const ( + uppercaseSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + lowercaseSet = "abcdefghijklmnopqrstuvwxyz" + numbersAndSymbolsSet = "0123456789*?@" + allCharsSet = uppercaseSet + lowercaseSet + numbersAndSymbolsSet + ) + uppercase, err := getRandomChars(uppercaseSet, 2) + if err != nil { + return "", err + } + lowercase, err := getRandomChars(lowercaseSet, 2) + if err != nil { + return "", err + } + numbersOrSymbols, err := getRandomChars(numbersAndSymbolsSet, 2) + if err != nil { + return "", err + } + allChars, err := getRandomChars(allCharsSet, 10) + if err != nil { + return "", err + } + res := []rune(uppercase + lowercase + numbersOrSymbols + allChars) + mrand.Shuffle(len(res), func(i, j int) { + res[i], res[j] = res[j], res[i] + }) + return string(res), nil +} + +func validatePassword(password string) bool { + if len(password) < 8 { + return false + } + hasCapital := regexp.MustCompile(`[A-Z]`).MatchString(password) + if !hasCapital { + return false + } + hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) + if !hasLower { + return false + } + hasNumberOrSymbol := regexp.MustCompile(`[0-9!@#$%^&*()_+\-=\[\]{};':"|,.<>?~]`).MatchString(password) + + return hasNumberOrSymbol } // Helper function to generate a JWT diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index aa71af4..5ac9414 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -6,6 +6,7 @@ import ( "log" "net/http" "net/http/httptest" + "regexp" "strings" "testing" @@ -103,12 +104,15 @@ const ( ) const ( - adminUser = `{"username": "testadmin", "password": "admin"}` - validUser = `{"username": "testuser", "password": "user"}` - invalidUser = `{"username": "", "password": ""}` - noPasswordUser = `{"username": "nopass", "password": ""}` - adminUserWrongPass = `{"username": "testadmin", "password": "wrongpass"}` - notExistingUser = `{"username": "not_existing", "password": "user"}` + adminUser = `{"username": "testadmin", "password": "Admin123"}` + validUser = `{"username": "testuser", "password": "userPass!"}` + invalidUser = `{"username": "", "password": ""}` + noPasswordUser = `{"username": "nopass", "password": ""}` + adminUserNewPassword = `{"id": 1, "password": "newPassword1"}` + userNewInvalidPassword = `{"id": 1, "password": "password"}` + userMissingPassword = `{"id": 1, "password": ""}` + adminUserWrongPass = `{"username": "testadmin", "password": "wrongpass"}` + notExistingUser = `{"username": "not_existing", "password": "user"}` ) func TestGoCertCertificatesHandlers(t *testing.T) { @@ -373,12 +377,6 @@ func TestGoCertUsersHandlers(t *testing.T) { ts := httptest.NewTLSServer(server.NewGoCertRouter(env)) defer ts.Close() - originalFunc := server.GeneratePassword - server.GeneratePassword = func(length int) (string, error) { - return "generatedPassword", nil - } - defer func() { server.GeneratePassword = originalFunc }() - client := ts.Client() testCases := []struct { @@ -394,7 +392,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "POST", path: "/api/v1/accounts", data: adminUser, - response: "{\"id\":1,\"password\":\"admin\"}", + response: "{\"id\":1,\"password\":\"Admin123\"}", status: http.StatusCreated, }, { @@ -410,7 +408,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "POST", path: "/api/v1/accounts", data: validUser, - response: "{\"id\":2,\"password\":\"user\"}", + response: "{\"id\":2,\"password\":\"userPass!\"}", status: http.StatusCreated, }, { @@ -418,7 +416,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "POST", path: "/api/v1/accounts", data: noPasswordUser, - response: "{\"id\":3,\"password\":\"generatedPassword\"}", + response: "{\"id\":3,\"password\":", status: http.StatusCreated, }, { @@ -445,6 +443,38 @@ func TestGoCertUsersHandlers(t *testing.T) { response: "error: Username is required", status: http.StatusBadRequest, }, + { + desc: "Change password success", + method: "POST", + path: "/api/v1/accounts/1/change_password", + data: adminUserNewPassword, + response: "1", + status: http.StatusOK, + }, + { + desc: "Change password failure no user", + method: "POST", + path: "/api/v1/accounts/100/change_password", + data: adminUserNewPassword, + response: "id not found", + status: http.StatusNotFound, + }, + { + desc: "Change password failure missing password", + method: "POST", + path: "/api/v1/accounts/1/change_password", + data: userMissingPassword, + response: "Password is required", + status: http.StatusBadRequest, + }, + { + desc: "Change password failure bad password", + method: "POST", + path: "/api/v1/accounts/1/change_password", + data: userNewInvalidPassword, + response: "Password does not meet requirements. It must include at least one capital letter, one lowercase letter, and either a number or a symbol.", + status: http.StatusBadRequest, + }, { desc: "Delete user success", method: "DELETE", @@ -480,6 +510,12 @@ func TestGoCertUsersHandlers(t *testing.T) { if res.StatusCode != tC.status || !strings.Contains(string(resBody), tC.response) { t.Errorf("expected response did not match.\nExpected vs Received status code: %d vs %d\nExpected vs Received body: \n%s\nvs\n%s\n", tC.status, res.StatusCode, tC.response, string(resBody)) } + if tC.desc == "Create no password user success" { + match, _ := regexp.MatchString(`"password":"[!-~]{16}"`, string(resBody)) + if !match { + t.Errorf("password does not match expected format or length: got %s", string(resBody)) + } + } }) } } @@ -495,12 +531,6 @@ func TestLogin(t *testing.T) { ts := httptest.NewTLSServer(server.NewGoCertRouter(env)) defer ts.Close() - originalFunc := server.GeneratePassword - server.GeneratePassword = func(length int) (string, error) { - return "generatedPassword", nil - } - defer func() { server.GeneratePassword = originalFunc }() - client := ts.Client() testCases := []struct { @@ -516,7 +546,7 @@ func TestLogin(t *testing.T) { method: "POST", path: "/api/v1/accounts", data: adminUser, - response: "{\"id\":1,\"password\":\"admin\"}", + response: "{\"id\":1,\"password\":\"Admin123\"}", status: http.StatusCreated, }, {