Skip to content

Commit

Permalink
feat: Adds login endpoint and handlers (#40)
Browse files Browse the repository at this point in the history
  • Loading branch information
saltiyazan authored Jul 9, 2024
1 parent 412bb63 commit 1d221b2
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 16 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/kr/text v0.2.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
Expand Down
61 changes: 61 additions & 0 deletions internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ import (
"net/http"
"strconv"
"strings"
"time"

"github.com/canonical/gocert/internal/certdb"
metrics "github.com/canonical/gocert/internal/metrics"
"github.com/canonical/gocert/ui"
"github.com/golang-jwt/jwt"
"golang.org/x/crypto/bcrypt"
)

// NewGoCertRouter takes in an environment struct, passes it along to any handlers that will need
Expand All @@ -36,6 +39,8 @@ func NewGoCertRouter(env *Environment) http.Handler {
apiV1Router.HandleFunc("POST /accounts", PostUserAccount(env))
apiV1Router.HandleFunc("DELETE /accounts/{id}", DeleteUserAccount(env))

apiV1Router.HandleFunc("POST /login", Login(env))

m := metrics.NewMetricsSubsystem(env.DB)
frontendHandler := newFrontendFileServer()

Expand Down Expand Up @@ -382,6 +387,47 @@ func DeleteUserAccount(env *Environment) http.HandlerFunc {
}
}

func Login(env *Environment) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var userRequest certdb.User
if err := json.NewDecoder(r.Body).Decode(&userRequest); err != nil {
logErrorAndWriteResponse("Invalid JSON format", http.StatusBadRequest, w)
return
}
if userRequest.Username == "" {
logErrorAndWriteResponse("Username is required", http.StatusBadRequest, w)
return
}
if userRequest.Password == "" {
logErrorAndWriteResponse("Password is required", http.StatusBadRequest, w)
return
}
userAccount, err := env.DB.RetrieveUserByUsername(userRequest.Username)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, certdb.ErrIdNotFound) {
logErrorAndWriteResponse("The username or password is incorrect. Try again.", http.StatusUnauthorized, w)
return
}
logErrorAndWriteResponse(err.Error(), status, w)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(userAccount.Password), []byte(userRequest.Password)); err != nil {
logErrorAndWriteResponse("The username or password is incorrect. Try again.", http.StatusUnauthorized, w)
return
}
jwt, err := generateJWT(userRequest.Username, env.JWTSecret, userAccount.Permissions)
if err != nil {
logErrorAndWriteResponse(err.Error(), http.StatusInternalServerError, w)
return
}
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(jwt)); err != nil {
logErrorAndWriteResponse(err.Error(), http.StatusInternalServerError, w)
}
}
}

// logErrorAndWriteResponse is a helper function that logs any error and writes it back as an http response
func logErrorAndWriteResponse(msg string, status int, w http.ResponseWriter) {
errMsg := fmt.Sprintf("error: %s", msg)
Expand All @@ -404,3 +450,18 @@ var GeneratePassword = func(length int) (string, error) {
}
return string(b), nil
}

// 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(),
})
tokenString, err := token.SignedString([]byte(jwtSecret))
if err != nil {
return "", err
}

return tokenString, nil
}
138 changes: 131 additions & 7 deletions internal/api/handlers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package server_test

import (
"fmt"
"io"
"log"
"net/http"
Expand All @@ -10,6 +11,7 @@ import (

server "github.com/canonical/gocert/internal/api"
"github.com/canonical/gocert/internal/certdb"
"github.com/golang-jwt/jwt"
)

const (
Expand Down Expand Up @@ -101,10 +103,12 @@ const (
)

const (
adminUser = `{"username": "testadmin", "password": "admin"}`
validUser = `{"username": "testuser", "password": "user"}`
noPasswordUser = `{"username": "nopass", "password": ""}`
invalidUser = `{"username": "", "password": ""}`
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"}`
)

func TestGoCertCertificatesHandlers(t *testing.T) {
Expand Down Expand Up @@ -268,7 +272,7 @@ func TestGoCertCertificatesHandlers(t *testing.T) {
method: "POST",
path: "/api/v1/certificate_requests/2/certificate",
data: validCert2,
response: "4",
response: "1",
status: http.StatusCreated,
},
{
Expand All @@ -284,7 +288,7 @@ func TestGoCertCertificatesHandlers(t *testing.T) {
method: "POST",
path: "/api/v1/certificate_requests/4/certificate/reject",
data: "",
response: "4",
response: "1",
status: http.StatusAccepted,
},
{
Expand All @@ -300,7 +304,7 @@ func TestGoCertCertificatesHandlers(t *testing.T) {
method: "DELETE",
path: "/api/v1/certificate_requests/2/certificate",
data: "",
response: "4",
response: "1",
status: http.StatusAccepted,
},
{
Expand Down Expand Up @@ -479,3 +483,123 @@ func TestGoCertUsersHandlers(t *testing.T) {
})
}
}

func TestLogin(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 = "secret"
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 {
desc string
method string
path string
data string
response string
status int
}{
{
desc: "Create admin user",
method: "POST",
path: "/api/v1/accounts",
data: adminUser,
response: "{\"id\":1,\"password\":\"admin\"}",
status: http.StatusCreated,
},
{
desc: "Login success",
method: "POST",
path: "/api/v1/login",
data: adminUser,
response: "",
status: http.StatusOK,
},
{
desc: "Login failure missing username",
method: "POST",
path: "/api/v1/login",
data: invalidUser,
response: "Username is required",
status: http.StatusBadRequest,
},
{
desc: "Login failure missing password",
method: "POST",
path: "/api/v1/login",
data: noPasswordUser,
response: "Password is required",
status: http.StatusBadRequest,
},
{
desc: "Login failure invalid password",
method: "POST",
path: "/api/v1/login",
data: adminUserWrongPass,
response: "error: The username or password is incorrect. Try again.",
status: http.StatusUnauthorized,
},
{
desc: "Login failure invalid username",
method: "POST",
path: "/api/v1/login",
data: notExistingUser,
response: "error: The username or password is incorrect. Try again.",
status: http.StatusUnauthorized,
},
}
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))
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 == "Login success" && res.StatusCode == http.StatusOK {
token, parseErr := jwt.Parse(string(resBody), 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 []byte(env.JWTSecret), nil
})
if parseErr != nil {
t.Errorf("Error parsing JWT: %v", parseErr)
return
}

if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
if claims["username"] != "testadmin" {
t.Errorf("Username found in JWT does not match expected value.")
} else if int(claims["permissions"].(float64)) != 1 {
t.Errorf("Permissions found in JWT does not match expected value.")
}
} else {
t.Errorf("Invalid JWT token or JWT claims are not readable")
}
}
})
}
}
16 changes: 16 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
package server

import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"log"
Expand All @@ -16,6 +18,7 @@ import (
type Environment struct {
DB *certdb.CertificateRequestsRepository
SendPebbleNotifications bool
JWTSecret string
}

func SendPebbleNotification(key, request_id string) error {
Expand All @@ -26,6 +29,14 @@ func SendPebbleNotification(key, request_id string) error {
return nil
}

func generateJWTSecret() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate JWT secret: %w", err)
}
return hex.EncodeToString(bytes), nil
}

// NewServer creates an environment and an http server with handlers that Go can start listening to
func NewServer(port int, cert []byte, key []byte, dbPath string, pebbleNotificationsEnabled bool) (*http.Server, error) {
serverCerts, err := tls.X509KeyPair(cert, key)
Expand All @@ -37,9 +48,14 @@ func NewServer(port int, cert []byte, key []byte, dbPath string, pebbleNotificat
log.Fatalf("Couldn't connect to database: %s", err)
}

jwtSecret, err := generateJWTSecret()
if err != nil {
return nil, err
}
env := &Environment{}
env.DB = db
env.SendPebbleNotifications = pebbleNotificationsEnabled
env.JWTSecret = jwtSecret
router := NewGoCertRouter(env)

s := &http.Server{
Expand Down
Loading

0 comments on commit 1d221b2

Please sign in to comment.