From 07752babb4e692452e1cd7f2133c4d8dde1b3b1c Mon Sep 17 00:00:00 2001 From: Michael Hunteman Date: Sun, 23 Jun 2024 13:55:42 -0700 Subject: Authenticate UI users --- client/src/apiSlice.ts | 10 ++--- client/src/components/Rsvp.tsx | 2 +- client/src/components/RsvpForm.tsx | 17 +------ client/src/features/auth/authSlice.ts | 9 +++- client/src/main.tsx | 27 +++++------- client/src/mocks/handlers.ts | 2 +- client/src/pages.ts | 2 +- server/cmd/main.go | 83 ++++++++++++++++++++++++++++++++++- server/go.mod | 10 +++-- server/go.sum | 14 +++--- server/guests/models.go | 17 +++++++ server/guests/store.go | 31 ++++++++++++- 12 files changed, 169 insertions(+), 55 deletions(-) diff --git a/client/src/apiSlice.ts b/client/src/apiSlice.ts index 5d987f9..7842da8 100644 --- a/client/src/apiSlice.ts +++ b/client/src/apiSlice.ts @@ -30,7 +30,7 @@ export interface PartyGuest { export const apiSlice = createApi({ reducerPath: 'api', baseQuery: fetchBaseQuery({ - baseUrl: '/', + baseUrl: 'http://localhost:8080/', prepareHeaders: (headers, { getState }) => { const token = (getState() as RootState).auth.token; if (token) { @@ -42,20 +42,20 @@ export const apiSlice = createApi({ tagTypes: ['Guests'], endpoints: (builder) => ({ getGuests: builder.query({ - query: () => '/guests', + query: () => 'guests', providesTags: ['Guests'], }), updateGuest: builder.mutation({ query: (guest) => ({ - url: `/guests/${guest?.id}`, - method: 'PATCH', + url: `guests/${guest?.id}`, + method: 'PUT', body: guest, providesTags: ['Guests'], }), }), login: builder.mutation({ query: (credentials) => ({ - url: '/guest-login', + url: 'guests/login', method: 'POST', body: credentials, }), diff --git a/client/src/components/Rsvp.tsx b/client/src/components/Rsvp.tsx index dad7213..fbcaf4a 100644 --- a/client/src/components/Rsvp.tsx +++ b/client/src/components/Rsvp.tsx @@ -22,7 +22,7 @@ function Rsvp() { ) : ( - + ); } diff --git a/client/src/components/RsvpForm.tsx b/client/src/components/RsvpForm.tsx index 71db0d8..9ac2d53 100644 --- a/client/src/components/RsvpForm.tsx +++ b/client/src/components/RsvpForm.tsx @@ -16,20 +16,6 @@ import { useForm, Controller, useFieldArray } from 'react-hook-form'; import { useUpdateGuestMutation } from '../apiSlice'; import type { Guest } from '../apiSlice'; -type FormValues = { - id: number; - firstName: string; - lastName: string; - attendance: string; - email: string; - partySize: number; - message: string; - partyList: { - firstName: string; - lastName: string; - }[]; -}; - function RsvpForm() { const [updateGuest] = useUpdateGuestMutation(); const guest: Guest = useOutletContext(); @@ -41,7 +27,7 @@ function RsvpForm() { control, watch, formState: { errors }, - } = useForm({ + } = useForm({ defaultValues: { id: guest?.id, firstName: guest?.firstName, @@ -163,6 +149,7 @@ function RsvpForm() { helperText={errors.partySize?.message} required {...register('partySize', { + valueAsNumber: true, onChange: handleParty, required: 'This field is required', min: { value: 1, message: 'Please enter a positive integer' }, diff --git a/client/src/features/auth/authSlice.ts b/client/src/features/auth/authSlice.ts index bff2bdd..878de0c 100644 --- a/client/src/features/auth/authSlice.ts +++ b/client/src/features/auth/authSlice.ts @@ -1,4 +1,5 @@ import { createSlice } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from '../../store'; import type { Guest } from '../../apiSlice'; @@ -11,8 +12,12 @@ const authSlice = createSlice({ name: 'auth', initialState: { guest: undefined, token: undefined } as AuthState, reducers: { - setCredentials: (state, action) => { - const { guest, token } = action.payload; + setCredentials: ( + state, + { + payload: { guest, token }, + }: PayloadAction<{ guest: Guest; token: string }> + ) => { state.guest = guest; state.token = token; }, diff --git a/client/src/main.tsx b/client/src/main.tsx index 8ada188..88999f7 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -31,7 +31,7 @@ const router = createBrowserRouter([ element: , }, { - path: 'guest-login', + path: 'guests/login', element: , }, { @@ -51,19 +51,12 @@ const router = createBrowserRouter([ }, ]); -const enableMocking = async () => { - const { worker } = await import('./mocks/browser'); - return worker.start(); -}; - -enableMocking().then(() => { - ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - - ); -}); +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +); diff --git a/client/src/mocks/handlers.ts b/client/src/mocks/handlers.ts index 217a7d5..0e882ee 100644 --- a/client/src/mocks/handlers.ts +++ b/client/src/mocks/handlers.ts @@ -4,7 +4,7 @@ import { nanoid } from '@reduxjs/toolkit'; const token = nanoid(); export const handlers = [ - http.post('/guest-login', () => { + http.post('/guests/login', () => { return HttpResponse.json({ guest: { id: 1, diff --git a/client/src/pages.ts b/client/src/pages.ts index bad57f6..a1dbdfd 100644 --- a/client/src/pages.ts +++ b/client/src/pages.ts @@ -1,7 +1,7 @@ const pages = [ { name: 'Home', to: '/' }, { name: 'Schedule', to: '/schedule' }, - { name: 'RSVP', to: '/guest-login' }, + { name: 'RSVP', to: '/guests/login' }, { name: 'Registry', to: '/registry' }, ]; diff --git a/server/cmd/main.go b/server/cmd/main.go index f886e2b..5b81b66 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "log" @@ -9,6 +11,7 @@ import ( "os" "regexp" + "github.com/golang-jwt/jwt/v5" "github.com/jackc/pgx/v5/pgxpool" "git.huntm.net/wedding/server/guests" @@ -19,6 +22,7 @@ type guestHandler struct { } type guestStore interface { + FindGuest(creds guests.Credentials) (guests.Guest, error) Get() ([]guests.Guest, error) Add(guest guests.Guest) error Update(guest guests.Guest) error @@ -41,6 +45,60 @@ func newGuestHandler(s guestStore) *guestHandler { } } +func (h *guestHandler) login(w http.ResponseWriter, r *http.Request) { + var creds guests.Credentials + err := json.NewDecoder(r.Body).Decode(&creds) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer r.Body.Close() + + guest, err := h.store.FindGuest(creds) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + claims := &guests.Claims{ + Guest: guest, + RegisteredClaims: jwt.RegisteredClaims{}, + } + + key := make([]byte, 32) + _, err = rand.Read(key) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + secretKey := []byte(base64.StdEncoding.EncodeToString(key)) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(secretKey) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + loginResponse := &guests.LoginResponse{ + Guest: guest, + Token: tokenString, + } + + jsonBytes, err := json.Marshal(loginResponse) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: tokenString, + }) + w.Write(jsonBytes) +} + func (h *guestHandler) getGuests(w http.ResponseWriter, _ *http.Request) { guests, err := h.store.Get() if err != nil { @@ -106,13 +164,17 @@ func (h *guestHandler) updateGuest(w http.ResponseWriter, r *http.Request) { err = h.store.Update(guest) if err != nil { - http.Error(w, "Guest not found", http.StatusBadRequest) + http.Error(w, "Cannot update guest", http.StatusBadRequest) return } } func (h *guestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { + case r.Method == http.MethodOptions: + w.WriteHeader(http.StatusOK) + case r.Method == http.MethodPost && r.URL.Path == "/guests/login": + h.login(w, r) case r.Method == http.MethodGet && guestRe.MatchString(r.URL.Path): h.getGuests(w, r) case r.Method == http.MethodPost && guestRe.MatchString(r.URL.Path): @@ -124,6 +186,23 @@ func (h *guestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func enableCors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isPreflight(r) { + w.Header().Add("Access-Control-Allow-Methods", "*") + } + w.Header().Add("Access-Control-Allow-Origin", "*") + w.Header().Add("Access-Control-Allow-Headers", "*") + next.ServeHTTP(w, r) + }) +} + +func isPreflight(r *http.Request) bool { + return r.Method == "OPTIONS" && + r.Header.Get("Origin") != "" && + r.Header.Get("Access-Control-Request-Method") != "" +} + func main() { db, err := pgxpool.New(context.Background(), fmt.Sprintf("postgres://%s:%s@%s:%s/%s", user, pass, host, port, database)) if err != nil { @@ -136,5 +215,5 @@ func main() { mux := http.NewServeMux() mux.Handle("/guests/", guestHandler) - log.Fatal(http.ListenAndServe(":8080", mux)) + log.Fatal(http.ListenAndServe(":8080", enableCors(mux))) } diff --git a/server/go.mod b/server/go.mod index 3ab08ef..49af49e 100644 --- a/server/go.mod +++ b/server/go.mod @@ -2,13 +2,17 @@ module git.huntm.net/wedding/server go 1.22.2 -require github.com/jackc/pgx/v5 v5.5.5 +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/jackc/pgx/v5 v5.5.5 +) require ( github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/stretchr/testify v1.8.4 // indirect golang.org/x/crypto v0.23.0 // indirect - golang.org/x/sync v0.1.0 // indirect + golang.org/x/sync v0.7.0 // indirect golang.org/x/text v0.15.0 // indirect ) diff --git a/server/go.sum b/server/go.sum index 23b6653..b93b74b 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,10 +1,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= @@ -14,12 +16,12 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/server/guests/models.go b/server/guests/models.go index c68a4c3..5915f81 100644 --- a/server/guests/models.go +++ b/server/guests/models.go @@ -1,5 +1,7 @@ package guests +import "github.com/golang-jwt/jwt/v5" + type Guest struct { Id int `json:"id"` FirstName string `json:"firstName"` @@ -15,3 +17,18 @@ type PartyGuest struct { FirstName string `json:"firstName"` LastName string `json:"lastName"` } + +type Credentials struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` +} + +type Claims struct { + Guest Guest `json:"guest"` + jwt.RegisteredClaims +} + +type LoginResponse struct { + Guest Guest `json:"guest"` + Token string `json:"token"` +} diff --git a/server/guests/store.go b/server/guests/store.go index 597cc80..bedc646 100644 --- a/server/guests/store.go +++ b/server/guests/store.go @@ -2,6 +2,7 @@ package guests import ( "context" + "errors" "github.com/jackc/pgx/v5/pgxpool" ) @@ -16,6 +17,26 @@ func NewMemStore(db *pgxpool.Pool) *MemStore { } } +func (m MemStore) FindGuest(creds Credentials) (Guest, error) { + rows, err := m.db.Query(context.Background(), "select * from guest") + var guest Guest + if err != nil { + return guest, err + } + defer rows.Close() + + for rows.Next() { + err := rows.Scan(&guest.Id, &guest.FirstName, &guest.LastName, &guest.Attendance, &guest.Email, &guest.Message, &guest.PartySize) + if err != nil { + return guest, err + } + if guest.FirstName == creds.FirstName && guest.LastName == creds.LastName { + return guest, nil + } + } + return guest, errors.New("Guest does not exist") +} + func (m MemStore) Get() ([]Guest, error) { rows, err := m.db.Query(context.Background(), "select * from guest") if err != nil { @@ -79,9 +100,15 @@ func (m MemStore) Update(guest Guest) error { return err } - statement = "update party set first_name = $1, last_name = $2 where guest_id = $3" + statement = "delete from party where guest_id = $1" + _, err = m.db.Exec(context.Background(), statement, guest.Id) + if err != nil { + return err + } + + statement = "insert into party (guest_id, first_name, last_name) values ($1, $2, $3)" for _, pg := range guest.PartyList { - _, err = m.db.Exec(context.Background(), statement, pg.FirstName, pg.LastName, guest.Id) + _, err = m.db.Exec(context.Background(), statement, guest.Id, pg.FirstName, pg.LastName) if err != nil { return err } -- cgit v1.2.3