diff --git a/README.md b/README.md index c04dec2..09b5ed8 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ services: ### Required for Production environment | Key | Description | Example | | - | - | - | -| `PUBLIC_URL` | Domain under which pepp is deployed | `https://stapel.example.com` | +| `PUBLIC_URL` | Domain under which stapel is deployed | `https://stapel.example.com` | | `SMTP_HOST` | E-Mail provider | `smtp.example.com` | | `SMTP_USER` | The user to log into the SMTP Server | `alice@example.com` | | `SMTP_PASSWORD` | The password to log into the SMTP Server | - | @@ -69,7 +69,7 @@ npm run dev ```bash cd server go generate ./... -go run server.go +ENV=Development go run server.go ``` ## Contributions diff --git a/frontend/components/providers/auth-provider.tsx b/frontend/components/providers/auth-provider.tsx index a2db149..638c7d4 100644 --- a/frontend/components/providers/auth-provider.tsx +++ b/frontend/components/providers/auth-provider.tsx @@ -1,6 +1,5 @@ "use client"; -import { deleteCookie, getCookie, setCookie } from "@/lib/cookie"; import { useIsActiveSessionQuery, useLogoutMutation, @@ -27,39 +26,23 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const router = useRouter(); const [isAuthenticated, setIsAuthenticated] = useState(false); - const [token, setToken] = useState(); - const { error: sessionError, data: sessionData } = useIsActiveSessionQuery({ - skip: !token, - variables: { token: token! }, - }); + const { data: sessionData, refetch: sessionRefetch } = + useIsActiveSessionQuery(); - if (sessionError) { - toast.error( - `Bei der Überprüfung der Session ist ein Fehler aufgetreten: ${sessionError.message}` - ); - } + useEffect(() => { + if (searchParams.get("l") === "1") { + router.push("/"); + sessionRefetch(); + } + }, [searchParams]); useEffect(() => { if (sessionData) { setIsAuthenticated(sessionData.isActiveSession); - if (sessionData.isActiveSession) setCookie("token", token!, 7); } }, [sessionData]); - useEffect(() => { - const tokenCookie = getCookie("token"); - if (tokenCookie) { - setToken(tokenCookie); - } - - const tokenSearchParam = searchParams.get("token"); - if (tokenSearchParam) { - setToken(tokenSearchParam); - router.push("/"); - } - }, [searchParams]); - const [triggerLogout, { error: logoutError }] = useLogoutMutation(); if (logoutError) { @@ -67,11 +50,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { } function logout() { - if (!token) return; - triggerLogout({ variables: { token: token } }); - deleteCookie("token"); - setToken(undefined); - setIsAuthenticated(false); + triggerLogout(); + sessionRefetch() } return ( diff --git a/frontend/lib/gql/generated/graphql.ts b/frontend/lib/gql/generated/graphql.ts index e53a2de..dee1883 100644 --- a/frontend/lib/gql/generated/graphql.ts +++ b/frontend/lib/gql/generated/graphql.ts @@ -36,7 +36,7 @@ export type Mutation = { __typename: 'Mutation'; createDeck: Scalars['String']['output']; deleteDeck: Scalars['String']['output']; - logout: Scalars['String']['output']; + logout: Scalars['Boolean']['output']; setValid: Scalars['String']['output']; updateDeck: Scalars['String']['output']; }; @@ -52,12 +52,6 @@ export type MutationDeleteDeckArgs = { hash: Scalars['String']['input']; }; - -export type MutationLogoutArgs = { - token: Scalars['String']['input']; -}; - - export type MutationSetValidArgs = { hash: Scalars['String']['input']; }; @@ -92,11 +86,6 @@ export type QueryDecksArgs = { year?: InputMaybe; }; - -export type QueryIsActiveSessionArgs = { - token: Scalars['String']['input']; -}; - export type CreateDeckMutationVariables = Exact<{ meta: NewDeck; file: Scalars['Upload']['input']; @@ -127,12 +116,10 @@ export type SetValidMutationVariables = Exact<{ export type SetValidMutation = { setValid: string }; -export type LogoutMutationVariables = Exact<{ - token: Scalars['String']['input']; -}>; +export type LogoutMutationVariables = Exact<{ [key: string]: never; }>; -export type LogoutMutation = { logout: string }; +export type LogoutMutation = { logout: boolean }; export type DecksQueryVariables = Exact<{ search?: InputMaybe; @@ -144,9 +131,7 @@ export type DecksQueryVariables = Exact<{ export type DecksQuery = { decks: Array<{ __typename: 'Deck', subject: string, module: string, moduleAlt: string, examiners: string, language: string, semester: string, year: number, hash: string, fileType: string, isValid: boolean }> | null }; -export type IsActiveSessionQueryVariables = Exact<{ - token: Scalars['String']['input']; -}>; +export type IsActiveSessionQueryVariables = Exact<{ [key: string]: never; }>; export type IsActiveSessionQuery = { isActiveSession: boolean }; @@ -177,7 +162,7 @@ export type CreateDeckMutationFn = typeof useCreateDeckMutation * }, * }); */ -export function useCreateDeckMutation(baseOptions?: Apollo.MutationFunctionOptions) { +export function useCreateDeckMutation(baseOptions?: Apollo.MutationHookOptions) { const options = {...defaultOptions, ...baseOptions} return Apollo.useMutation(CreateDeckDocument, options); } @@ -279,8 +264,8 @@ export type SetValidMutationHookResult = ReturnType; export type SetValidMutationResult = Apollo.MutationResult; export type SetValidMutationOptions = Apollo.MutationFunctionOptions; export const LogoutDocument = gql` - mutation Logout($token: String!) { - logout(token: $token) + mutation Logout { + logout } `; export type LogoutMutationFn = typeof useLogoutMutation @@ -298,7 +283,6 @@ export type LogoutMutationFn = typeof useLogoutMutation * @example * const [logoutMutation, { data, loading, error }] = useLogoutMutation({ * variables: { - * token: // value for 'token' * }, * }); */ @@ -362,8 +346,8 @@ export type DecksLazyQueryHookResult = ReturnType; export type DecksSuspenseQueryHookResult = ReturnType; export type DecksQueryResult = Apollo.QueryResult; export const IsActiveSessionDocument = gql` - query IsActiveSession($token: String!) { - isActiveSession(token: $token) + query IsActiveSession { + isActiveSession } `; @@ -379,11 +363,10 @@ export const IsActiveSessionDocument = gql` * @example * const { data, loading, error } = useIsActiveSessionQuery({ * variables: { - * token: // value for 'token' * }, * }); */ -export function useIsActiveSessionQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: IsActiveSessionQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { +export function useIsActiveSessionQuery(baseOptions?: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} return Apollo.useQuery(IsActiveSessionDocument, options); } diff --git a/frontend/lib/gql/mutations/session.graphql b/frontend/lib/gql/mutations/session.graphql index 047580e..82d7baf 100644 --- a/frontend/lib/gql/mutations/session.graphql +++ b/frontend/lib/gql/mutations/session.graphql @@ -1,3 +1,3 @@ -mutation Logout($token: String!) { - logout(token: $token) +mutation Logout { + logout } diff --git a/frontend/lib/gql/queries/session.graphql b/frontend/lib/gql/queries/session.graphql index 2152a53..6c352e3 100644 --- a/frontend/lib/gql/queries/session.graphql +++ b/frontend/lib/gql/queries/session.graphql @@ -1,3 +1,3 @@ -query IsActiveSession($token: String!) { - isActiveSession(token: $token) +query IsActiveSession { + isActiveSession } diff --git a/frontend/lib/graphql.ts b/frontend/lib/graphql.ts index e9eb0b2..6f49e9a 100644 --- a/frontend/lib/graphql.ts +++ b/frontend/lib/graphql.ts @@ -1,21 +1,12 @@ -import { ApolloClient, ApolloLink, InMemoryCache } from '@apollo/client'; -import UploadHttpLink from 'apollo-upload-client/UploadHttpLink.mjs'; -import { getCookie } from './cookie'; - -const authLink = new ApolloLink((operation, forward) => { - const token = getCookie('token'); - operation.setContext(({ headers = {} }) => ({ - headers: { ...headers, TOKEN: token ?? '' }, - })); - return forward(operation); -}); +import { ApolloClient, InMemoryCache } from "@apollo/client"; +import UploadHttpLink from "apollo-upload-client/UploadHttpLink.mjs"; const uploadLink = new UploadHttpLink({ - uri: '/graphql', - credentials: 'include', + uri: "/graphql", + credentials: "include", }); export const client = new ApolloClient({ - link: authLink.concat(uploadLink), + link: uploadLink, cache: new InMemoryCache(), }); diff --git a/frontend/package.json b/frontend/package.json index d7dc652..65b3aff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,12 +1,13 @@ { "name": "cards", - "version": "3.2.0", + "version": "3.2.1", "private": true, "scripts": { "dev": "next dev --turbopack", "build": "next build --turbopack", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "codegen": "graphql-codegen --config codegen.ts" }, "dependencies": { "@apollo/client": "^4.0.7", diff --git a/server/graph/generated.go b/server/graph/generated.go index 2ce5f05..cfcebeb 100644 --- a/server/graph/generated.go +++ b/server/graph/generated.go @@ -64,14 +64,14 @@ type ComplexityRoot struct { Mutation struct { CreateDeck func(childComplexity int, meta models.Deck, file graphql.Upload) int DeleteDeck func(childComplexity int, hash string) int - Logout func(childComplexity int, token string) int + Logout func(childComplexity int) int SetValid func(childComplexity int, hash string) int UpdateDeck func(childComplexity int, hash string, meta models.Deck) int } Query struct { Decks func(childComplexity int, search *string, languages []string, semester *string, year *int) int - IsActiveSession func(childComplexity int, token string) int + IsActiveSession func(childComplexity int) int } } @@ -80,11 +80,11 @@ type MutationResolver interface { UpdateDeck(ctx context.Context, hash string, meta models.Deck) (string, error) DeleteDeck(ctx context.Context, hash string) (string, error) SetValid(ctx context.Context, hash string) (string, error) - Logout(ctx context.Context, token string) (string, error) + Logout(ctx context.Context) (bool, error) } type QueryResolver interface { Decks(ctx context.Context, search *string, languages []string, semester *string, year *int) ([]*models.Deck, error) - IsActiveSession(ctx context.Context, token string) (bool, error) + IsActiveSession(ctx context.Context) (bool, error) } type executableSchema struct { @@ -194,12 +194,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - args, err := ec.field_Mutation_logout_args(ctx, rawArgs) - if err != nil { - return 0, false - } - - return e.complexity.Mutation.Logout(childComplexity, args["token"].(string)), true + return e.complexity.Mutation.Logout(childComplexity), true case "Mutation.setValid": if e.complexity.Mutation.SetValid == nil { break @@ -239,12 +234,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - args, err := ec.field_Query_isActiveSession_args(ctx, rawArgs) - if err != nil { - return 0, false - } - - return e.complexity.Query.IsActiveSession(childComplexity, args["token"].(string)), true + return e.complexity.Query.IsActiveSession(childComplexity), true } return 0, false @@ -398,17 +388,6 @@ func (ec *executionContext) field_Mutation_deleteDeck_args(ctx context.Context, return args, nil } -func (ec *executionContext) field_Mutation_logout_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { - var err error - args := map[string]any{} - arg0, err := graphql.ProcessArgField(ctx, rawArgs, "token", ec.unmarshalNString2string) - if err != nil { - return nil, err - } - args["token"] = arg0 - return args, nil -} - func (ec *executionContext) field_Mutation_setValid_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -473,17 +452,6 @@ func (ec *executionContext) field_Query_decks_args(ctx context.Context, rawArgs return args, nil } -func (ec *executionContext) field_Query_isActiveSession_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { - var err error - args := map[string]any{} - arg0, err := graphql.ProcessArgField(ctx, rawArgs, "token", ec.unmarshalNString2string) - if err != nil { - return nil, err - } - args["token"] = arg0 - return args, nil -} - func (ec *executionContext) field___Directive_args_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1036,37 +1004,38 @@ func (ec *executionContext) _Mutation_logout(ctx context.Context, field graphql. field, ec.fieldContext_Mutation_logout, func(ctx context.Context) (any, error) { - fc := graphql.GetFieldContext(ctx) - return ec.resolvers.Mutation().Logout(ctx, fc.Args["token"].(string)) + return ec.resolvers.Mutation().Logout(ctx) }, - nil, - ec.marshalNString2string, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + if ec.directives.Auth == nil { + var zeroVal bool + return zeroVal, errors.New("directive auth is not implemented") + } + return ec.directives.Auth(ctx, nil, directive0) + } + + next = directive1 + return next + }, + ec.marshalNBoolean2bool, true, true, ) } -func (ec *executionContext) fieldContext_Mutation_logout(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Mutation_logout(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Mutation", Field: field, IsMethod: true, IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") + return nil, errors.New("field of type Boolean does not have child fields") }, } - defer func() { - if r := recover(); r != nil { - err = ec.Recover(ctx, r) - ec.Error(ctx, err) - } - }() - ctx = graphql.WithFieldContext(ctx, fc) - if fc.Args, err = ec.field_Mutation_logout_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { - ec.Error(ctx, err) - return fc, err - } return fc, nil } @@ -1140,8 +1109,7 @@ func (ec *executionContext) _Query_isActiveSession(ctx context.Context, field gr field, ec.fieldContext_Query_isActiveSession, func(ctx context.Context) (any, error) { - fc := graphql.GetFieldContext(ctx) - return ec.resolvers.Query().IsActiveSession(ctx, fc.Args["token"].(string)) + return ec.resolvers.Query().IsActiveSession(ctx) }, nil, ec.marshalNBoolean2bool, @@ -1150,7 +1118,7 @@ func (ec *executionContext) _Query_isActiveSession(ctx context.Context, field gr ) } -func (ec *executionContext) fieldContext_Query_isActiveSession(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_isActiveSession(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -1160,17 +1128,6 @@ func (ec *executionContext) fieldContext_Query_isActiveSession(ctx context.Conte return nil, errors.New("field of type Boolean does not have child fields") }, } - defer func() { - if r := recover(); r != nil { - err = ec.Recover(ctx, r) - ec.Error(ctx, err) - } - }() - ctx = graphql.WithFieldContext(ctx, fc) - if fc.Args, err = ec.field_Query_isActiveSession_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { - ec.Error(ctx, err) - return fc, err - } return fc, nil } diff --git a/server/graph/schema.graphqls b/server/graph/schema.graphqls index 6e1ea8d..ee63dcf 100644 --- a/server/graph/schema.graphqls +++ b/server/graph/schema.graphqls @@ -17,7 +17,7 @@ type Deck { type Query { decks(search: String, languages: [String!], semester: String, year: Int): [Deck!] - isActiveSession(token: String!): Boolean! + isActiveSession: Boolean! } input NewDeck { @@ -35,5 +35,5 @@ type Mutation { updateDeck(hash: String!, meta: NewDeck!): String! @auth deleteDeck(hash: String!): String! @auth setValid(hash: String!): String! @auth - logout(token: String!): String! + logout: Boolean! @auth } diff --git a/server/graph/schema.resolvers.go b/server/graph/schema.resolvers.go index 40a4494..c484ae8 100644 --- a/server/graph/schema.resolvers.go +++ b/server/graph/schema.resolvers.go @@ -11,12 +11,14 @@ import ( "encoding/hex" "fmt" "io" + "net/http" "os" "path/filepath" "strconv" "time" "github.com/99designs/gqlgen/graphql" + "github.com/FachschaftMathPhysInfo/cards/server/middleware" "github.com/FachschaftMathPhysInfo/cards/server/models" "github.com/FachschaftMathPhysInfo/cards/server/utils" "github.com/sirupsen/logrus" @@ -142,16 +144,28 @@ func (r *mutationResolver) SetValid(ctx context.Context, hash string) (string, e } // Logout is the resolver for the logout field. -func (r *mutationResolver) Logout(ctx context.Context, token string) (string, error) { +func (r *mutationResolver) Logout(ctx context.Context) (bool, error) { + token, exists := ctx.Value("token").(string) + if !exists { + return false, fmt.Errorf("No token provided") + } + if _, err := r.DB.NewDelete(). Model((*models.Session)(nil)). Where("token = ?", token). Exec(ctx); err != nil { logrus.Error(err) - return "", fmt.Errorf("Internal Server Error") + return false, fmt.Errorf("Internal Server Error") } - return token, nil + // Delete token cookie + rw := middleware.GetResponseWriter(ctx) + http.SetCookie(rw, &http.Cookie{ + Name: "token", + MaxAge: -1, + }) + + return true, nil } // Decks is the resolver for the decks field. @@ -195,23 +209,27 @@ func (r *queryResolver) Decks(ctx context.Context, search *string, languages []s } // IsActiveSession is the resolver for the isActiveSession field. -func (r *queryResolver) IsActiveSession(ctx context.Context, token string) (bool, error) { +func (r *queryResolver) IsActiveSession(ctx context.Context) (bool, error) { + token, exists := ctx.Value("token").(string) + if !exists { + return false, nil + } + session := new(models.Session) if err := r.DB.NewSelect(). Model(session). Where("token = ?", token). Scan(ctx); err != nil { - logrus.Error(err) - return false, fmt.Errorf("Internal Server Error") + return false, nil } if session == nil { - return false, fmt.Errorf("No active session found") + return false, nil } if session.ExpiresAt.Before(time.Now()) { - r.Mutation().Logout(ctx, token) - return false, fmt.Errorf("Session is expired") + r.Mutation().Logout(ctx) + return false, nil } return true, nil diff --git a/server/middleware/rw.go b/server/middleware/rw.go new file mode 100644 index 0000000..e7d9a0a --- /dev/null +++ b/server/middleware/rw.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "context" + "net/http" +) + +type responseWriterKey struct{} + +func WithResponseWriter(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), responseWriterKey{}, w) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func GetResponseWriter(ctx context.Context) http.ResponseWriter { + rw, _ := ctx.Value(responseWriterKey{}).(http.ResponseWriter) + return rw +} diff --git a/server/server.go b/server/server.go index 4649196..371e4e3 100644 --- a/server/server.go +++ b/server/server.go @@ -3,11 +3,11 @@ package main import ( "context" "fmt" - "log" "net/http" "net/http/httputil" "net/url" "os" + "time" "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/handler" @@ -15,6 +15,7 @@ import ( "github.com/99designs/gqlgen/graphql/playground" "github.com/FachschaftMathPhysInfo/cards/server/db" "github.com/FachschaftMathPhysInfo/cards/server/graph" + mw "github.com/FachschaftMathPhysInfo/cards/server/middleware" "github.com/FachschaftMathPhysInfo/cards/server/utils" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -23,11 +24,13 @@ import ( ) const ( - defaultPort = "8080" - tokenHeader = "TOKEN" + defaultPort = "8080" + tokenCookieName = "token" ) func main() { + isDevelopment := os.Getenv("ENV") == "Development" + publicUrl := os.Getenv("PUBLIC_URL") if publicUrl == "" { publicUrl = "http://localhost:8080" @@ -44,19 +47,18 @@ func main() { gqlResolver := graph.Resolver{DB: db} gc := graph.Config{Resolvers: &gqlResolver} gc.Directives.Auth = func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) { - token, _ := ctx.Value("token").(string) - isValid, _ := gqlResolver.Query().IsActiveSession(ctx, token) + isValid, _ := gqlResolver.Query().IsActiveSession(ctx) if isValid { return next(ctx) } - return + return nil, fmt.Errorf("Access denied") } router := chi.NewRouter() // Set up CORS router.Use(cors.New(cors.Options{ - AllowedHeaders: []string{"*"}, + AllowedHeaders: []string{tokenCookieName}, AllowCredentials: true, Debug: false, }).Handler) @@ -71,7 +73,17 @@ func main() { http.Error(w, err.Error(), http.StatusInternalServerError) } - http.Redirect(w, r, fmt.Sprintf("%s?token=%s", publicUrl, token), http.StatusFound) + http.SetCookie(w, &http.Cookie{ + Name: tokenCookieName, + Value: token, + Path: "/", + Expires: time.Now().Add(7 * 24 * time.Hour), + Secure: !isDevelopment, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + + http.Redirect(w, r, publicUrl+"?l=1", http.StatusSeeOther) }) // Serve GraphQL endpoint @@ -80,14 +92,14 @@ func main() { MaxMemory: 32 << 20, MaxUploadSize: 100 << 20, }) - router.Handle("/graphql", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + router.Handle("/graphql", mw.WithResponseWriter(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reqCtx := r.Context() - token := r.Header.Get(tokenHeader) - if token != "" { - reqCtx = context.WithValue(reqCtx, "token", token) + c, _ := r.Cookie(tokenCookieName) + if c != nil && c.Value != "" { + reqCtx = context.WithValue(reqCtx, "token", c.Value) } srv.ServeHTTP(w, r.WithContext(reqCtx)) - })) + }))) // Serve deck files fileServer := http.StripPrefix("/deckfiles/", http.FileServer(http.Dir("./storage/deckfiles"))) @@ -97,8 +109,10 @@ func main() { router.Handle("/*", httputil.NewSingleHostReverseProxy(frontendUrl)) // Serve GraphQL playground - router.Handle("/playground", playground.Handler("GraphQL playground", "/graphql")) + if isDevelopment { + router.Handle("/playground", playground.Handler("GraphQL playground", "/graphql")) + } - log.Printf("Server running on http://localhost:%s", defaultPort) - log.Fatal(http.ListenAndServe(":"+defaultPort, router)) + logrus.Infof("Server running on http://localhost:%s", defaultPort) + logrus.Fatal(http.ListenAndServe(":"+defaultPort, router)) }