diff --git a/go.mod b/go.mod index 65d6a6b..87e5d65 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.16 require ( github.com/alexflint/go-arg v1.4.2 + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/gorilla/mux v1.8.0 github.com/graph-gophers/dataloader v5.0.0+incompatible github.com/graphql-go/graphql v0.7.9 diff --git a/go.sum b/go.sum index 0019e80..ecd472d 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ 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/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/httpServer.go b/internal/httpServer.go index 3e025ea..35e7fdd 100644 --- a/internal/httpServer.go +++ b/internal/httpServer.go @@ -7,7 +7,10 @@ import ( "mime" "net/http" "path/filepath" + "strings" + "time" + "github.com/golang-jwt/jwt" "github.com/gorilla/mux" "github.com/graphql-go/graphql" "github.com/graphql-go/graphql/gqlerrors" @@ -17,6 +20,10 @@ import ( log "github.com/sirupsen/logrus" ) +type JWTClaims struct { + jwt.StandardClaims +} + // initHttp setup and start the http server. Blocking func initHttp(resolveContext context.Context, schema graphql.Schema, address string) error { r := mux.NewRouter() @@ -39,7 +46,17 @@ func initHttp(resolveContext context.Context, schema graphql.Schema, address str r.Use(func(h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - // TODO: handle auth + + token := getTokenFromRequest(r) + + if token != "" { + parsedToken, err := parseJWT(token) + + if err == nil && parsedToken.Valid { + r.WithContext(context.WithValue(r.Context(), "jwt", parsedToken)) + } + } + h.ServeHTTP(rw, r) }) }) @@ -56,6 +73,8 @@ func initHttp(resolveContext context.Context, schema graphql.Schema, address str httpPostFile(resolveContext, rw, r) }).Methods("POST") + r.HandleFunc("/api/cookie", setLoginCookie).Methods("POST") + // Init the embedded static files initStatic(r) @@ -126,3 +145,87 @@ func httpPostFile(ctx context.Context, rw http.ResponseWriter, r *http.Request) rw.WriteHeader(http.StatusCreated) } + +//parseJWT parse a JWT. does not check if token is valid. Returns error if non was provided +func parseJWT(token string) (*jwt.Token, error) { + if token == "" { + return nil, fmt.Errorf("No token provided") + } + + parsed, err := jwt.Parse(token, 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("TODO"), nil + }) + + if err != nil { + return nil, err + } + + return parsed, nil + +} + +//getTokenFromRequest looks for a JWT in the "Authorization" header or in the cookies +func getTokenFromRequest(r *http.Request) string { + // get token from header + authHeader := strings.Split(r.Header.Get("Authorization"), "Bearer ") + if len(authHeader) == 2 { + return authHeader[1] + } + + // Get cookie from cookie + cookie, err := r.Cookie("jwt") //TODO: change cookie name + + if err == nil && len(cookie.Value) != 0 { + return cookie.Value + } + + return "" +} + +//setLoginCookie if provieded a valid JWT in the body then set a httpOnly cookie with the token +func setLoginCookie(rw http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + defer r.Body.Close() + + if err != nil { + rw.WriteHeader(http.StatusInternalServerError) + return + } + + tokenString := string(body) + + token, err := parseJWT(tokenString) + + if err != nil { + rw.WriteHeader(http.StatusInternalServerError) + return + } + + if !token.Valid { + rw.WriteHeader(http.StatusUnauthorized) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + + if !ok { + rw.WriteHeader(http.StatusInternalServerError) + return + } + + cookie := &http.Cookie{ + Name: "jwt", + Value: tokenString, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Path: "/api", + Expires: time.Unix(int64(claims["exp"].(float64)), 0), + } + + http.SetCookie(rw, cookie) + + rw.WriteHeader(http.StatusNoContent) +} diff --git a/internal/mutations.go b/internal/mutations.go index a9b80e9..6d5d0ec 100644 --- a/internal/mutations.go +++ b/internal/mutations.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "strings" + "time" + "github.com/golang-jwt/jwt" "github.com/graph-gophers/dataloader" "github.com/minio/minio-go/v7" ) @@ -189,3 +191,21 @@ func deleteDirectory(ctx context.Context, path string) error { return nil } + +//login Checks for valid username password combination. Returns singed jwt string +func login(ctx context.Context, username, password string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, JWTClaims{ + StandardClaims: jwt.StandardClaims{ + Subject: username, + ExpiresAt: time.Now().Add(time.Hour * 24).Unix(), + }, + }) + + tokenString, err := token.SignedString([]byte("TODO")) + + if err != nil { + return "", err + } + + return tokenString, nil +} diff --git a/internal/schema.go b/internal/schema.go index d49abf1..6aa6e71 100644 --- a/internal/schema.go +++ b/internal/schema.go @@ -183,6 +183,32 @@ func graphqlSchema() (graphql.Schema, error) { return path, deleteDirectory(p.Context, path) }, }, + "login": &graphql.Field{ + Type: graphql.NewNonNull(graphql.String), + Args: graphql.FieldConfigArgument{ + "username": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + "password": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + username, ok := p.Args["username"].(string) + + if !ok { + return nil, fmt.Errorf("Failed to parse args") + } + + password, ok := p.Args["password"].(string) + + if !ok { + return nil, fmt.Errorf("Failed to parse args") + } + + return login(p.Context, username, password) + }, + }, } rootQuery := graphql.ObjectConfig{