Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Adds an endpoint and handlers to change user account password #39

Merged
merged 9 commits into from
Jul 9, 2024
108 changes: 101 additions & 7 deletions internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"io/fs"
"log"
"math/big"
mrand "math/rand"
"net/http"
"regexp"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
kayra1 marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down Expand Up @@ -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
Expand Down
74 changes: 52 additions & 22 deletions internal/api/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
},
{
Expand All @@ -410,15 +408,15 @@ 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,
},
{
desc: "Create no password user success",
method: "POST",
path: "/api/v1/accounts",
data: noPasswordUser,
response: "{\"id\":3,\"password\":\"generatedPassword\"}",
response: "{\"id\":3,\"password\":",
status: http.StatusCreated,
},
{
Expand All @@ -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",
Expand Down Expand Up @@ -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))
}
}
})
}
}
Expand All @@ -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 {
Expand All @@ -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,
},
{
Expand Down
Loading