diff --git a/.github/workflows/build-rock.yaml b/.github/workflows/build-rock.yaml index 1e3d0ba..2d83bba 100644 --- a/.github/workflows/build-rock.yaml +++ b/.github/workflows/build-rock.yaml @@ -42,6 +42,8 @@ jobs: - name: Test if pebble notify fires correctly id: test_notify run : | + curl -XPOST -k -d '{"username":"admin", "password": "Admin1234"}' https://localhost:3000/api/v1/accounts + export ADMIN_TOKEN=$(curl -XPOST -k -d '{"username":"admin", "password": "Admin1234"}' https://localhost:3000/login) curl -XPOST -k -d '-----BEGIN CERTIFICATE REQUEST----- MIIC5zCCAc8CAQAwRzEWMBQGA1UEAwwNMTAuMTUyLjE4My41MzEtMCsGA1UELQwk MzlhY2UxOTUtZGM1YS00MzJiLTgwOTAtYWZlNmFiNGI0OWNmMIIBIjANBgkqhkiG @@ -59,7 +61,7 @@ jobs: cAQXk3fvTWuikHiCHqqdSdjDYj/8cyiwCrQWpV245VSbOE0WesWoEnSdFXVUfE1+ RSKeTRuuJMcdGqBkDnDI22myj0bjt7q8eqBIjTiLQLnAFnQYpcCrhc8dKU9IJlv1 H9Hay4ZO9LRew3pEtlx2WrExw/gpUcWM8rTI - -----END CERTIFICATE REQUEST-----' 'https://localhost:3000/api/v1/certificate_requests' + -----END CERTIFICATE REQUEST-----' -H "Authorization: Bearer $ADMIN_TOKEN" 'https://localhost:3000/api/v1/certificate_requests' curl -XPOST -k -d '-----BEGIN CERTIFICATE----- MIIDrDCCApSgAwIBAgIURKr+jf7hj60SyAryIeN++9wDdtkwDQYJKoZIhvcNAQEL BQAwOTELMAkGA1UEBhMCVVMxKjAoBgNVBAMMIXNlbGYtc2lnbmVkLWNlcnRpZmlj @@ -81,7 +83,7 @@ jobs: gCX3nqYpp70oZIFDrhmYwE5ij5KXlHD4/1IOfNUKCDmQDgGPLI1tVtwQLjeRq7Hg XVelpl/LXTQawmJyvDaVT/Q9P+WqoDiMjrqF6Sy7DzNeeccWVqvqX5TVS6Ky56iS Mvo/+PAJHkBciR5Xn+Wg2a+7vrZvT6CBoRSOTozlLSM= - -----END CERTIFICATE-----' 'https://localhost:3000/api/v1/certificate_requests/1/certificate' + -----END CERTIFICATE-----' -H "Authorization: Bearer $ADMIN_TOKEN" 'https://localhost:3000/api/v1/certificate_requests/1/certificate' docker exec gocert /usr/bin/pebble notices docker exec gocert /usr/bin/pebble notices | grep gocert\\.com/certificate/update docker exec gocert /usr/bin/pebble notice 3 diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 237ca4f..6a0d180 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -42,19 +42,23 @@ func NewGoCertRouter(env *Environment) http.Handler { apiV1Router.HandleFunc("DELETE /accounts/{id}", DeleteUserAccount(env)) apiV1Router.HandleFunc("POST /accounts/{id}/change_password", ChangeUserAccountPassword(env)) - apiV1Router.HandleFunc("POST /login", Login(env)) - m := metrics.NewMetricsSubsystem(env.DB) frontendHandler := newFrontendFileServer() router := http.NewServeMux() + router.HandleFunc("POST /login", Login(env)) router.HandleFunc("/status", HealthCheck) router.Handle("/metrics", m.Handler) router.Handle("/api/v1/", http.StripPrefix("/api/v1", apiV1Router)) router.Handle("/", frontendHandler) - ctx := middlewareContext{metrics: m} + ctx := middlewareContext{ + metrics: m, + jwtSecret: env.JWTSecret, + firstAccountIssued: false, + } middleware := createMiddlewareStack( + authMiddleware(&ctx), metricsMiddleware(&ctx), loggingMiddleware(&ctx), ) @@ -550,16 +554,24 @@ func validatePassword(password string) bool { } // Helper function to generate a JWT -func generateJWT(username, jwtSecret string, permissions int) (string, error) { - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "username": username, - "permissions": permissions, - "exp": time.Now().Add(time.Hour * 1).Unix(), +func generateJWT(username string, jwtSecret []byte, permissions int) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwtGocertClaims{ + Username: username, + Permissions: permissions, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: time.Now().Add(time.Hour * 1).Unix(), + }, }) - tokenString, err := token.SignedString([]byte(jwtSecret)) + tokenString, err := token.SignedString(jwtSecret) if err != nil { return "", err } return tokenString, nil } + +type jwtGocertClaims struct { + Username string `json:"username"` + Permissions int `json:"permissions"` + jwt.StandardClaims +} diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 589fd96..46b18e5 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -127,6 +127,10 @@ func TestGoCertCertificatesHandlers(t *testing.T) { client := ts.Client() + var adminToken string + var nonAdminToken string + t.Run("prepare user accounts and tokens", prepareUserAccounts(ts.URL, client, &adminToken, &nonAdminToken)) + testCases := []struct { desc string method string @@ -347,6 +351,7 @@ func TestGoCertCertificatesHandlers(t *testing.T) { for _, tC := range testCases { t.Run(tC.desc, func(t *testing.T) { req, err := http.NewRequest(tC.method, ts.URL+tC.path, strings.NewReader(tC.data)) + req.Header.Set("Authorization", "Bearer "+adminToken) if err != nil { t.Fatal(err) } @@ -379,43 +384,43 @@ func TestGoCertUsersHandlers(t *testing.T) { client := ts.Client() + var adminToken string + var nonAdminToken string + t.Run("prepare user accounts and tokens", prepareUserAccounts(ts.URL, client, &adminToken, &nonAdminToken)) + testCases := []struct { desc string method string path string data string + auth string response string status int }{ - { - desc: "Create first user success", - method: "POST", - path: "/api/v1/accounts", - data: adminUser, - response: "{\"id\":1}", - status: http.StatusCreated, - }, { desc: "Retrieve admin user success", method: "GET", path: "/api/v1/accounts/1", data: "", + auth: adminToken, response: "{\"id\":1,\"username\":\"testadmin\",\"permissions\":1}", status: http.StatusOK, }, { - desc: "Create second user success", - method: "POST", - path: "/api/v1/accounts", - data: validUser, - response: "{\"id\":2}", - status: http.StatusCreated, + desc: "Retrieve admin user fail", + method: "GET", + path: "/api/v1/accounts/1", + data: "", + auth: nonAdminToken, + response: "error: forbidden", + status: http.StatusForbidden, }, { desc: "Create no password user success", method: "POST", path: "/api/v1/accounts", data: noPasswordUser, + auth: adminToken, response: "{\"id\":3,\"password\":", status: http.StatusCreated, }, @@ -424,6 +429,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "GET", path: "/api/v1/accounts/2", data: "", + auth: adminToken, response: "{\"id\":2,\"username\":\"testuser\",\"permissions\":0}", status: http.StatusOK, }, @@ -432,6 +438,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "GET", path: "/api/v1/accounts/300", data: "", + auth: adminToken, response: "error: id not found", status: http.StatusNotFound, }, @@ -440,6 +447,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "POST", path: "/api/v1/accounts", data: invalidUser, + auth: adminToken, response: "error: Username is required", status: http.StatusBadRequest, }, @@ -448,6 +456,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "POST", path: "/api/v1/accounts/1/change_password", data: adminUserNewPassword, + auth: adminToken, response: "1", status: http.StatusOK, }, @@ -456,6 +465,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "POST", path: "/api/v1/accounts/100/change_password", data: adminUserNewPassword, + auth: adminToken, response: "id not found", status: http.StatusNotFound, }, @@ -464,6 +474,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "POST", path: "/api/v1/accounts/1/change_password", data: userMissingPassword, + auth: adminToken, response: "Password is required", status: http.StatusBadRequest, }, @@ -472,6 +483,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "POST", path: "/api/v1/accounts/1/change_password", data: userNewInvalidPassword, + auth: adminToken, response: "Password must have 8 or more characters, must include at least one capital letter, one lowercase letter, and either a number or a symbol.", status: http.StatusBadRequest, }, @@ -480,6 +492,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "DELETE", path: "/api/v1/accounts/2", data: invalidUser, + auth: adminToken, response: "1", status: http.StatusAccepted, }, @@ -488,6 +501,7 @@ func TestGoCertUsersHandlers(t *testing.T) { method: "DELETE", path: "/api/v1/accounts/2", data: invalidUser, + auth: adminToken, response: "error: id not found", status: http.StatusNotFound, }, @@ -495,6 +509,7 @@ func TestGoCertUsersHandlers(t *testing.T) { for _, tC := range testCases { t.Run(tC.desc, func(t *testing.T) { req, err := http.NewRequest(tC.method, ts.URL+tC.path, strings.NewReader(tC.data)) + req.Header.Add("Authorization", "Bearer "+tC.auth) if err != nil { t.Fatal(err) } @@ -527,7 +542,7 @@ func TestLogin(t *testing.T) { } env := &server.Environment{} env.DB = testdb - env.JWTSecret = "secret" + env.JWTSecret = []byte("secret") ts := httptest.NewTLSServer(server.NewGoCertRouter(env)) defer ts.Close() @@ -552,7 +567,7 @@ func TestLogin(t *testing.T) { { desc: "Login success", method: "POST", - path: "/api/v1/login", + path: "/login", data: adminUser, response: "", status: http.StatusOK, @@ -560,7 +575,7 @@ func TestLogin(t *testing.T) { { desc: "Login failure missing username", method: "POST", - path: "/api/v1/login", + path: "/login", data: invalidUser, response: "Username is required", status: http.StatusBadRequest, @@ -568,7 +583,7 @@ func TestLogin(t *testing.T) { { desc: "Login failure missing password", method: "POST", - path: "/api/v1/login", + path: "/login", data: noPasswordUser, response: "Password is required", status: http.StatusBadRequest, @@ -576,7 +591,7 @@ func TestLogin(t *testing.T) { { desc: "Login failure invalid password", method: "POST", - path: "/api/v1/login", + path: "/login", data: adminUserWrongPass, response: "error: The username or password is incorrect. Try again.", status: http.StatusUnauthorized, @@ -584,7 +599,7 @@ func TestLogin(t *testing.T) { { desc: "Login failure invalid username", method: "POST", - path: "/api/v1/login", + path: "/login", data: notExistingUser, response: "error: The username or password is incorrect. Try again.", status: http.StatusUnauthorized, @@ -633,3 +648,227 @@ func TestLogin(t *testing.T) { }) } } + +func TestAuthorization(t *testing.T) { + testdb, err := certdb.NewCertificateRequestsRepository(":memory:", "CertificateRequests") + if err != nil { + log.Fatalf("couldn't create test sqlite db: %s", err) + } + env := &server.Environment{} + env.DB = testdb + env.JWTSecret = []byte("secret") + ts := httptest.NewTLSServer(server.NewGoCertRouter(env)) + defer ts.Close() + + client := ts.Client() + var adminToken string + var nonAdminToken string + t.Run("prepare user accounts and tokens", prepareUserAccounts(ts.URL, client, &adminToken, &nonAdminToken)) + + testCases := []struct { + desc string + method string + path string + data string + auth string + response string + status int + }{ + { + desc: "metrics reachable without auth", + method: "GET", + path: "/metrics", + data: "", + auth: "", + response: "# HELP certificate_requests Total number of certificate requests", + status: http.StatusOK, + }, + { + desc: "status reachable without auth", + method: "GET", + path: "/status", + data: "", + auth: "", + response: "", + status: http.StatusOK, + }, + { + desc: "missing endpoints produce 404", + method: "GET", + path: "/this/path/does/not/exist", + data: "", + auth: nonAdminToken, + response: "", + status: http.StatusNotFound, + }, + { + desc: "nonadmin can't see accounts", + method: "GET", + path: "/api/v1/accounts", + data: "", + auth: nonAdminToken, + response: "", + status: http.StatusForbidden, + }, + { + desc: "admin can see accounts", + method: "GET", + path: "/api/v1/accounts", + data: "", + auth: adminToken, + response: `[{"id":1,"username":"testadmin","permissions":1},{"id":2,"username":"testuser","permissions":0}]`, + status: http.StatusOK, + }, + { + desc: "nonadmin can't delete admin account", + method: "DELETE", + path: "/api/v1/accounts/1", + data: "", + auth: nonAdminToken, + response: "", + status: http.StatusForbidden, + }, + { + desc: "user can't change admin password", + method: "POST", + path: "/api/v1/accounts/1/change_password", + data: `{"password":"Pwnd123!"}`, + auth: nonAdminToken, + response: "", + status: http.StatusForbidden, + }, + { + desc: "admin can't delete itself", + method: "DELETE", + path: "/api/v1/accounts/1", + data: "", + auth: adminToken, + response: "error: can't delete admin account", + status: http.StatusConflict, + }, + { + desc: "admin can delete nonuser", + method: "DELETE", + path: "/api/v1/accounts/2", + data: "", + auth: adminToken, + response: "1", + status: http.StatusAccepted, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + req, err := http.NewRequest(tC.method, ts.URL+tC.path, strings.NewReader(tC.data)) + req.Header.Add("Authorization", "Bearer "+tC.auth) + if err != nil { + t.Fatal(err) + } + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + resBody, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Fatal(err) + } + 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)) + } + } + }) + } +} + +func prepareUserAccounts(url string, client *http.Client, adminToken, nonAdminToken *string) func(*testing.T) { + return func(t *testing.T) { + req, err := http.NewRequest("POST", url+"/api/v1/accounts", strings.NewReader(adminUser)) + if err != nil { + t.Fatal(err) + } + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _, err = io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusCreated { + t.Fatalf("creating the first request should succeed when unauthorized. status code received: %d", res.StatusCode) + } + req, err = http.NewRequest("POST", url+"/api/v1/accounts", strings.NewReader(validUser)) + if err != nil { + t.Fatal(err) + } + res, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + _, err = io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Fatalf("the second request should have been rejected. status code received: %d", res.StatusCode) + } + req, err = http.NewRequest("POST", url+"/login", strings.NewReader(adminUser)) + if err != nil { + t.Fatal(err) + } + res, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + resBody, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("the admin login request should have succeeded. status code received: %d", res.StatusCode) + } + *adminToken = string(resBody) + req, err = http.NewRequest("POST", url+"/api/v1/accounts", strings.NewReader(validUser)) + req.Header.Set("Authorization", "Bearer "+*adminToken) + if err != nil { + t.Fatal(err) + } + res, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + _, err = io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusCreated { + t.Fatalf("creating the second request should have succeeded when given the admin auth header. status code received: %d", res.StatusCode) + } + req, err = http.NewRequest("POST", url+"/login", strings.NewReader(validUser)) + if err != nil { + t.Fatal(err) + } + res, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + resBody, err = io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Errorf("the admin login request should have succeeded. status code received: %d", res.StatusCode) + } + *nonAdminToken = string(resBody) + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 1a978b0..75b952b 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -1,11 +1,15 @@ package server import ( + "fmt" "log" "net/http" + "regexp" + "strconv" "strings" "github.com/canonical/gocert/internal/metrics" + "github.com/golang-jwt/jwt" "github.com/prometheus/client_golang/prometheus/promhttp" ) @@ -15,6 +19,8 @@ type middleware func(http.Handler) http.Handler type middlewareContext struct { responseStatusCode int metrics *metrics.PrometheusMetrics + jwtSecret []byte + firstAccountIssued bool } // The responseWriterCloner struct wraps the http.ResponseWriter struct, and extracts the status @@ -84,3 +90,78 @@ func loggingMiddleware(ctx *middlewareContext) middleware { }) } } + +// authMiddleware intercepts requests that need authorization to check if the user's token exists and is +// permitted to use the endpoint +func authMiddleware(ctx *middlewareContext) middleware { + AdminOnlyPaths := []struct{ method, path string }{ + {"POST", `accounts`}, + {"GET", `accounts`}, + {"GET", `accounts\/\d+$`}, + {"DELETE", `accounts\/\d+$`}, + {"POST", `accounts\/\d+\/change_password$`}, + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/api/v1/") { + next.ServeHTTP(w, r) + return + } + if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "accounts") && !ctx.firstAccountIssued { + next.ServeHTTP(w, r) + if strings.HasPrefix(strconv.Itoa(ctx.responseStatusCode), "2") { + ctx.firstAccountIssued = true + } + return + } + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + logErrorAndWriteResponse("authorization header not found", http.StatusUnauthorized, w) + return + } + bearerToken := strings.Split(authHeader, " ") + if len(bearerToken) != 2 || bearerToken[0] != "Bearer" { + logErrorAndWriteResponse("authorization header couldn't be processed. The expected format is 'Bearer '", http.StatusUnauthorized, w) + return + } + claims, err := getClaimsFromJWT(bearerToken[1], ctx.jwtSecret) + if err != nil { + logErrorAndWriteResponse(fmt.Sprintf("token is not valid: %s", err.Error()), http.StatusUnauthorized, w) + return + } + if claims.Permissions == 0 { + for _, v := range AdminOnlyPaths { + matched, err := regexp.Match(v.path, []byte(r.URL.Path)) + if err != nil { + logErrorAndWriteResponse(fmt.Sprintf("ran into issue parsing path: %s", err.Error()), http.StatusInternalServerError, w) + return + } + if r.Method == v.method && matched { + logErrorAndWriteResponse("forbidden", http.StatusForbidden, w) + return + } + } + } + if claims.Permissions == 1 && r.Method == "DELETE" && strings.HasSuffix(r.URL.Path, "accounts/1") { + logErrorAndWriteResponse("can't delete admin account", http.StatusConflict, w) + return + } + next.ServeHTTP(w, r) + }) + } +} + +func getClaimsFromJWT(bearerToken string, jwtSecret []byte) (*jwtGocertClaims, error) { + claims := jwtGocertClaims{} + token, err := jwt.ParseWithClaims(bearerToken, &claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtSecret, nil + }) + if err != nil || !token.Valid { + return nil, err + } + return &claims, nil +} diff --git a/internal/api/server.go b/internal/api/server.go index 30b78f1..6fd3887 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -4,7 +4,6 @@ package server import ( "crypto/rand" "crypto/tls" - "encoding/hex" "errors" "fmt" "log" @@ -18,7 +17,7 @@ import ( type Environment struct { DB *certdb.CertificateRequestsRepository SendPebbleNotifications bool - JWTSecret string + JWTSecret []byte } func SendPebbleNotification(key, request_id string) error { @@ -29,12 +28,12 @@ func SendPebbleNotification(key, request_id string) error { return nil } -func generateJWTSecret() (string, error) { +func generateJWTSecret() ([]byte, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { - return "", fmt.Errorf("failed to generate JWT secret: %w", err) + return bytes, fmt.Errorf("failed to generate JWT secret: %w", err) } - return hex.EncodeToString(bytes), nil + return bytes, nil } // NewServer creates an environment and an http server with handlers that Go can start listening to