Skip to content

Commit

Permalink
feat: Adds an endpoint and handlers to change user account password (#39
Browse files Browse the repository at this point in the history
)
  • Loading branch information
saltiyazan committed Jul 9, 2024
1 parent 1d221b2 commit 40998f6
Show file tree
Hide file tree
Showing 2 changed files with 153 additions and 29 deletions.
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)
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

0 comments on commit 40998f6

Please sign in to comment.