diff --git a/.gitignore b/.gitignore index 251e8ca55..ccae96394 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ node_modules .vscode .trunk .env.dev -.env.prod \ No newline at end of file +.env.prod + +frontend/sac-mobile/ios/ +frontend/sac-mobile/android/ \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index a438f1636..7eb4ffc97 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,7 +3,7 @@ module github.com/GenerateNU/sac/backend go 1.22.1 require ( - github.com/clerkinc/clerk-sdk-go v1.49.0 + github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 github.com/garrettladley/mattress v0.4.0 github.com/go-playground/validator/v10 v10.19.0 github.com/goccy/go-json v0.10.2 @@ -27,10 +27,10 @@ require ( require ( github.com/awnumar/memcall v0.2.0 // indirect github.com/awnumar/memguard v0.22.4 // indirect - github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/philhofer/fwd v1.1.2 // indirect + github.com/smartystreets/goconvey v1.8.1 // indirect github.com/tinylib/msgp v1.1.8 // indirect golang.org/x/sync v0.6.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 43f0f5014..9a2c72cc2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,14 +1,13 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 h1:rFw4nCn9iMW+Vajsk51NtYIcwSTkXr+JGrMd36kTDJw= +github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/awnumar/memcall v0.2.0 h1:sRaogqExTOOkkNwO9pzJsL8jrOV29UuUW7teRMfbqtI= github.com/awnumar/memcall v0.2.0/go.mod h1:S911igBPR9CThzd/hYQQmTc9SWNu3ZHIlCGaWsWsoJo= github.com/awnumar/memguard v0.22.4 h1:1PLgKcgGPeExPHL8dCOWGVjIbQUBgJv9OL0F/yE1PqQ= github.com/awnumar/memguard v0.22.4/go.mod h1:+APmZGThMBWjnMlKiSM1X7MVpbIVewen2MTkqWkA/zE= -github.com/brianvoe/gofakeit/v6 v6.19.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= -github.com/clerkinc/clerk-sdk-go v1.49.0 h1:tJLIAx3qfP2cNQJ/iPq6OF1BSB0NzI3alcOuEueexoA= -github.com/clerkinc/clerk-sdk-go v1.49.0/go.mod h1:pejhMTTDAuw5aBpiHBEOOOHMAsxNfPvKfM5qexFJYlc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -21,9 +20,6 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/garrettladley/mattress v0.4.0 h1:ZB3iqyc5q6bqIryNfsh2FMcbMdnV1XEryvqivouceQE= github.com/garrettladley/mattress v0.4.0/go.mod h1:OWKIRc9wC3gtD3Ng/nUuNEiR1TJvRYLmn/KZYw9nl5Q= -github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= -github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= -github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= @@ -48,12 +44,12 @@ github.com/gofiber/swagger v1.0.0 h1:BzUzDS9ZT6fDUa692kxmfOjc1DZiloLiPK/W5z1H1tc github.com/gofiber/swagger v1.0.0/go.mod h1:QrYNF1Yrc7ggGK6ATsJ6yfH/8Zi5bu9lA7wB8TmCecg= 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.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= @@ -78,6 +74,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -120,6 +118,10 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -134,7 +136,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -160,28 +161,20 @@ go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -190,46 +183,32 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/backend/src/auth/clerk.go b/backend/src/auth/clerk.go deleted file mode 100644 index 9b5fd42f2..000000000 --- a/backend/src/auth/clerk.go +++ /dev/null @@ -1,50 +0,0 @@ -package auth - -import ( - "github.com/GenerateNU/sac/backend/src/config" - "github.com/clerkinc/clerk-sdk-go/clerk" -) - -type ClerkServiceInterface interface { - // Register(email, password string) *errors.Error - // Login(email, password string) (string, *errors.Error) - GetAllUsers() (string, error) -} - -type ClerkService struct { - Settings config.ClerkSettings - client clerk.Client -} - -func NewClerkService(settings config.ClerkSettings) *ClerkService { - client, err := clerk.NewClient(settings.APIKey.Expose()) - if err != nil { - return nil - } - - return &ClerkService{ - Settings: settings, - client: client, - } -} - -// func (c *ClerkService) Register(email, password string) *errors.Error { -// _, err := clerk.(c.context, email, password) -// if err != nil { -// return errors.NewError(err) -// } - -// return nil -// } - -func (c *ClerkService) GetAllUsers(limit int, offset int) ([]clerk.User, error) { - users, err := c.client.Users().ListAll(clerk.ListAllUsersParams{ - Limit: &limit, - Offset: &offset, - }) - if err != nil { - return nil, err - } - - return users, nil -} \ No newline at end of file diff --git a/backend/src/auth/custom_claims.go b/backend/src/auth/custom_claims.go deleted file mode 100644 index c3655560b..000000000 --- a/backend/src/auth/custom_claims.go +++ /dev/null @@ -1,26 +0,0 @@ -package auth - -import ( - "github.com/GenerateNU/sac/backend/src/errors" - "github.com/gofiber/fiber/v2" - "github.com/golang-jwt/jwt" -) - -type CustomClaims struct { - jwt.StandardClaims - Role string `json:"role"` -} - -func From(c *fiber.Ctx) (*CustomClaims, *errors.Error) { - rawClaims := c.Locals("claims") - if rawClaims == nil { - return nil, nil - } - - claims, ok := rawClaims.(*CustomClaims) - if !ok { - return nil, &errors.FailedToCastToCustomClaims - } - - return claims, nil -} diff --git a/backend/src/auth/jwt.go b/backend/src/auth/jwt.go new file mode 100644 index 000000000..26da955e4 --- /dev/null +++ b/backend/src/auth/jwt.go @@ -0,0 +1,300 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/GenerateNU/sac/backend/src/config" + "github.com/GenerateNU/sac/backend/src/errors" + m "github.com/garrettladley/mattress" + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt" +) + +type CustomClaims struct { + jwt.StandardClaims + Role string `json:"role"` +} + +// From extracts the CustomClaims from the fiber context +// Returns nil if the claims are not present +func From(c *fiber.Ctx) (*CustomClaims, *errors.Error) { + rawClaims := c.Locals("claims") + if rawClaims == nil { + return nil, nil + } + + fmt.Println("rawClaims", rawClaims) + + claims, ok := rawClaims.(*CustomClaims) + if !ok { + return nil, &errors.FailedToCastToCustomClaims + } + + return claims, nil +} + +type JWTType string + +const ( + AccessToken JWTType = "access" + RefreshToken JWTType = "refresh" +) + +type Token struct { + AccessToken []byte + RefreshToken []byte +} + +type Claims struct { + StandardClaims *jwt.StandardClaims + CustomClaims *jwt.MapClaims +} + +type JWTClientInterface interface { + GenerateTokenPair(accessClaims, refreshClaims Claims) (*Token, *errors.Error) + GenerateToken(claims Claims, tokenType JWTType) ([]byte, *errors.Error) + RefreshToken(token, refreshToken string, tokenType JWTType, newClaims jwt.MapClaims) ([]byte, *errors.Error) + ExtractClaims(tokenString string, tokenType JWTType) (jwt.MapClaims, *errors.Error) + ParseToken(tokenString string, tokenType JWTType) (*jwt.Token, *errors.Error) + IsTokenValid(tokenString string, tokenType JWTType) (bool, *errors.Error) +} + +type JWTClient struct { + RefreshExp time.Duration + AccessExp time.Duration + RefreshKey *m.Secret[string] + AccessKey *m.Secret[string] + SigningMethod jwt.SigningMethod +} + +func NewJWTClient(authSettings config.AuthSettings, signingMethod jwt.SigningMethod) *JWTClient { + return &JWTClient{ + RefreshExp: time.Hour * 24 * time.Duration(authSettings.RefreshTokenExpiry), + AccessExp: time.Minute * time.Duration(authSettings.AccessTokenExpiry), + RefreshKey: authSettings.RefreshKey, + AccessKey: authSettings.AccessKey, + SigningMethod: signingMethod, + } +} + +func (j *JWTClient) GenerateTokenPair(accessClaims, refreshClaims Claims) (*Token, *errors.Error) { + accessToken, err := j.GenerateToken(accessClaims, AccessToken) + if err != nil { + return nil, err + } + + refreshToken, err := j.GenerateToken(refreshClaims, RefreshToken) + if err != nil { + return nil, err + } + + return &Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + }, nil +} + +func (j *JWTClient) GenerateToken(claims Claims, tokenType JWTType) ([]byte, *errors.Error) { + // create a new map to store the combined claims + combinedClaims := make(jwt.MapClaims) + + // copy the standard claims to the combined claims if they are present + if claims.CustomClaims != nil { + copyCustomClaims(&combinedClaims, *claims.CustomClaims) + } + + // copy the custom claims to the combined claims if they are present + if claims.StandardClaims != nil { + copyStandardClaims(&combinedClaims, *claims.StandardClaims) + } + + // get the secret key for the token type + secretKey, err := j.getSecretKey(tokenType) + if err != nil { + return nil, err + } + + // get the expiry for the token type + exp := j.getExpiry(tokenType) + + // set the expiry of the token if its a value in the client, otherwise use whatever was passed in + if exp != 0 { + combinedClaims["exp"] = time.Now().Add(exp).Unix() + } + + // create a new token with the combined claims + token := jwt.NewWithClaims(j.SigningMethod, combinedClaims) + signedToken, signErr := token.SignedString([]byte(secretKey)) + if signErr != nil { + return nil, &errors.FailedToSignToken + } + + return []byte(signedToken), nil +} + +func (j *JWTClient) ParseToken(tokenString string, tokenType JWTType) (*jwt.Token, *errors.Error) { + secretKey, err := j.getSecretKey(tokenType) + if err != nil { + return nil, err + } + + token, parseErr := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(secretKey), nil + }) + if parseErr != nil { + return nil, &errors.FailedToParseToken + } + + return token, nil +} + +func (j *JWTClient) ExtractClaims(tokenString string, tokenType JWTType) (jwt.MapClaims, *errors.Error) { + token, err := j.ParseToken(tokenString, tokenType) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, err + } + + return claims, nil +} + +func (j *JWTClient) IsTokenValid(tokenString string, tokenType JWTType) (bool, *errors.Error) { + token, err := j.ParseToken(tokenString, tokenType) + if err != nil { + return false, err + } + + return token.Valid, nil +} + +func (j *JWTClient) RefreshToken(token, refreshToken string, tokenType JWTType, newClaims jwt.MapClaims) ([]byte, *errors.Error) { + // check if the refresh token is valid + ok, err := j.IsTokenValid(refreshToken, RefreshToken) + if err != nil || !ok { + return nil, err + } + + // extract the claims from the token + claims, err := j.ExtractClaims(token, tokenType) + if err != nil { + return nil, err + } + + // 1. update the issued at claim + // 2. update the expires at claim if its present in the config + // 3. give the new claims priority over the old claims + claims = updateIssuedAt(claims) + claims = updateExpiresAt(claims, j.getExpiry(tokenType)) + claims = emendClaims(claims, newClaims) + + newToken, err := j.GenerateToken(Claims{CustomClaims: &claims}, tokenType) + if err != nil { + return nil, err + } + + return newToken, nil +} + +func (j *JWTClient) getSecretKey(tokenType JWTType) (string, *errors.Error) { + switch tokenType { + case AccessToken: + return j.AccessKey.Expose(), nil + case RefreshToken: + return j.RefreshKey.Expose(), nil + } + + return "", &errors.InvalidTokenType +} + +func (j *JWTClient) getExpiry(tokenType JWTType) time.Duration { + switch tokenType { + case AccessToken: + return j.AccessExp + case RefreshToken: + return j.RefreshExp + } + + return 0 +} + +// updateIssuedAt updates the issued at claim of the token. +// If the issued at claim is not present, it won't be added. +func updateIssuedAt(claims jwt.MapClaims) jwt.MapClaims { + if _, ok := claims["iat"]; ok { + claims["iat"] = time.Now().Unix() + } + return claims +} + +// updateExpiresAt updates the expires at claim of the token. +// If the expires at claim is not present, it won't be added. +func updateExpiresAt(claims jwt.MapClaims, exp time.Duration) jwt.MapClaims { + if _, ok := claims["exp"]; ok { + claims["exp"] = time.Now().Add(exp).Unix() + } + return claims +} + +// emendClaims updates the claims of the token with the new claims. +// If the claim is not present in the original claims, it won't be added. +func emendClaims(originalClaims, newClaims jwt.MapClaims) jwt.MapClaims { + for key, value := range newClaims { + if _, ok := originalClaims[key]; ok { + originalClaims[key] = value + } + } + return originalClaims +} + +// copyStandardClaims copies the standard claims from a jwt.StandardClaims instance to a jwt.MapClaims instance. +// It is a utility function used to copy standard claims to the token claims. +func copyStandardClaims(claims *jwt.MapClaims, standardClaims jwt.StandardClaims) { + claimMapping := map[string]interface{}{ + "exp": standardClaims.ExpiresAt, + "iss": standardClaims.Issuer, + "aud": standardClaims.Audience, + "iat": standardClaims.IssuedAt, + "nbf": standardClaims.NotBefore, + "sub": standardClaims.Subject, + "jti": standardClaims.Id, + } + + for key, value := range claimMapping { + if intValue, ok := value.(int64); ok && intValue != 0 { + (*claims)[key] = value + } else if strValue, ok := value.(string); ok && strValue != "" { + (*claims)[key] = value + } + } +} + +// copyCustomClaims copies the custom claims from a map[string]interface{} instance to a jwt.MapClaims instance. +// It is a utility function used to copy custom claims to the token claims. +func copyCustomClaims(claims *jwt.MapClaims, customClaims map[string]interface{}) { + for key, value := range customClaims { + (*claims)[key] = value + } +} + +// func GenerateRefreshCookie(value string) *fiber.Cookie { +// return &fiber.Cookie{ +// Name: "refresh_token", +// Value: value, +// Expires: time.Now().Add(j.RefreshExp), +// HTTPOnly: true, +// } +// } + +// func ExpireCookie(name string) *fiber.Cookie { +// return &fiber.Cookie{ +// Name: name, +// Value: "", +// Expires: time.Now().Add(-time.Hour), +// HTTPOnly: true, +// } +// } diff --git a/backend/src/auth/tokens.go b/backend/src/auth/tokens.go deleted file mode 100644 index 3f14bc8f2..000000000 --- a/backend/src/auth/tokens.go +++ /dev/null @@ -1,187 +0,0 @@ -package auth - -import ( - "time" - - "github.com/GenerateNU/sac/backend/src/config" - "github.com/GenerateNU/sac/backend/src/errors" - - m "github.com/garrettladley/mattress" - "github.com/gofiber/fiber/v2" - "github.com/golang-jwt/jwt" -) - -func CreateTokenPair(id string, role string, authSettings config.AuthSettings) (*string, *string, *errors.Error) { - accessToken, catErr := CreateAccessToken(id, role, authSettings.AccessTokenExpiry, authSettings.AccessKey) - if catErr != nil { - return nil, nil, catErr - } - - refreshToken, crtErr := CreateRefreshToken(id, authSettings.RefreshTokenExpiry, authSettings.RefreshKey) - if crtErr != nil { - return nil, nil, crtErr - } - - return accessToken, refreshToken, nil -} - -// CreateAccessToken creates a new access token for the user -func CreateAccessToken(id string, role string, accessExpiresAfter uint, accessToken *m.Secret[string]) (*string, *errors.Error) { - if id == "" || role == "" { - return nil, &errors.FailedToCreateAccessToken - } - - accessTokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, &CustomClaims{ - StandardClaims: jwt.StandardClaims{ - IssuedAt: time.Now().Unix(), - Issuer: id, - ExpiresAt: time.Now().Add(time.Minute * time.Duration(accessExpiresAfter)).Unix(), - }, - Role: role, - }) - - returnedAccessToken, err := SignToken(accessTokenClaims, accessToken) - if err != nil { - return nil, err - } - - return returnedAccessToken, nil -} - -// CreateRefreshToken creates a new refresh token for the user -func CreateRefreshToken(id string, refreshExpiresAfter uint, refreshKey *m.Secret[string]) (*string, *errors.Error) { - if id == "" { - return nil, &errors.FailedToCreateRefreshToken - } - - refreshTokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.StandardClaims{ - IssuedAt: time.Now().Unix(), - Issuer: id, - ExpiresAt: time.Now().Add(time.Hour * 24 * time.Duration(refreshExpiresAfter)).Unix(), - }) - - returnedRefreshToken, err := SignToken(refreshTokenClaims, refreshKey) - if err != nil { - return nil, err - } - - return returnedRefreshToken, nil -} - -func SignToken(token *jwt.Token, key *m.Secret[string]) (*string, *errors.Error) { - if token == nil || key.Expose() == "" { - return nil, &errors.FailedToSignToken - } - - tokenString, err := token.SignedString([]byte(key.Expose())) - if err != nil { - return nil, &errors.FailedToSignToken - } - return &tokenString, nil -} - -// CreateCookie creates a new cookie -func CreateCookie(name string, value string, expires time.Time) *fiber.Cookie { - return &fiber.Cookie{ - Name: name, - Value: value, - Expires: expires, - HTTPOnly: true, - } -} - -// ExpireCookie expires a cookie -func ExpireCookie(name string) *fiber.Cookie { - return &fiber.Cookie{ - Name: name, - Value: "", - Expires: time.Now().Add(-time.Hour), - HTTPOnly: true, - } -} - -// RefreshAccessToken refreshes the access token -func RefreshAccessToken(refreshCookie string, role string, refreshKey *m.Secret[string], accessExpiresAfter uint, accessKey *m.Secret[string]) (*string, *errors.Error) { - // Parse the refresh token - refreshToken, err := ParseRefreshToken(refreshCookie, refreshKey) - if err != nil { - return nil, &errors.FailedToParseRefreshToken - } - - // Extract the claims from the refresh token - claims, ok := refreshToken.Claims.(*jwt.StandardClaims) - if !ok || !refreshToken.Valid { - return nil, &errors.FailedToValidateRefreshToken - } - - // Create a new access token - accessToken, catErr := CreateAccessToken(claims.Issuer, role, accessExpiresAfter, accessKey) - if catErr != nil { - return nil, &errors.FailedToCreateAccessToken - } - - return accessToken, nil -} - -// ParseAccessToken parses the access token -func ParseAccessToken(cookie string, accessKey *m.Secret[string]) (*jwt.Token, error) { - return jwt.ParseWithClaims(cookie, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) { - return []byte(accessKey.Expose()), nil - }) -} - -// ParseRefreshToken parses the refresh token -func ParseRefreshToken(cookie string, refreshKey *m.Secret[string]) (*jwt.Token, error) { - return jwt.ParseWithClaims(cookie, &jwt.StandardClaims{}, func(token *jwt.Token) (interface{}, error) { - return []byte(refreshKey.Expose()), nil - }) -} - -// GetRoleFromToken gets the role from the custom claims -func GetRoleFromToken(tokenString string, accessKey *m.Secret[string]) (*string, error) { - token, err := ParseAccessToken(tokenString, accessKey) - if err != nil { - return nil, err - } - - claims, ok := token.Claims.(*CustomClaims) - if !ok || !token.Valid { - return nil, &errors.FailedToValidateAccessToken - } - - return &claims.Role, nil -} - -// ExtractClaims extracts the claims from the token -func ExtractAccessClaims(tokenString string, accessKey *m.Secret[string]) (*CustomClaims, *errors.Error) { - token, err := ParseAccessToken(tokenString, accessKey) - if err != nil { - return nil, &errors.FailedToParseAccessToken - } - - claims, ok := token.Claims.(*CustomClaims) - if !ok || !token.Valid { - return nil, &errors.FailedToValidateAccessToken - } - - return claims, nil -} - -// ExtractClaims extracts the claims from the token -func ExtractRefreshClaims(tokenString string, refreshKey *m.Secret[string]) (*jwt.StandardClaims, *errors.Error) { - token, err := ParseRefreshToken(tokenString, refreshKey) - if err != nil { - return nil, &errors.FailedToParseRefreshToken - } - - claims, ok := token.Claims.(*jwt.StandardClaims) - if !ok || !token.Valid { - return nil, &errors.FailedToValidateRefreshToken - } - - return claims, nil -} - -func IsBlacklisted(token string) bool { - return false -} diff --git a/backend/src/config/clerk.go b/backend/src/config/clerk.go deleted file mode 100644 index 1ec21ea02..000000000 --- a/backend/src/config/clerk.go +++ /dev/null @@ -1,28 +0,0 @@ -package config - -import ( - "errors" - "os" - - m "github.com/garrettladley/mattress" -) - -type ClerkSettings struct { - APIKey *m.Secret[string] -} - -func readClerkSettings() (*ClerkSettings, error) { - apiKey := os.Getenv("SAC_CLERK_SECRET_KEY") - if apiKey == "" { - return nil, errors.New("SAC_CLERK_SECRET_KEY is not set") - } - - secretAPIKey, err := m.NewSecret(apiKey) - if err != nil { - return nil, errors.New("failed to create secret from api key") - } - - return &ClerkSettings{ - APIKey: secretAPIKey, - }, nil -} diff --git a/backend/src/config/config.go b/backend/src/config/config.go index bf54394e0..e58e3ba5e 100644 --- a/backend/src/config/config.go +++ b/backend/src/config/config.go @@ -14,7 +14,6 @@ type Settings struct { PineconeSettings PineconeSettings OpenAISettings OpenAISettings ResendSettings ResendSettings - ClerkSettings ClerkSettings } type intermediateSettings struct { diff --git a/backend/src/config/local.go b/backend/src/config/local.go index fd35af43c..f5214c8a4 100644 --- a/backend/src/config/local.go +++ b/backend/src/config/local.go @@ -58,12 +58,5 @@ func readLocal(v *viper.Viper, path string, useDevDotEnv bool) (*Settings, error settings.ResendSettings = *resendSettings - clerkSettings, err := readClerkSettings() - if err != nil { - return nil, fmt.Errorf("failed to read Clerk settings: %w", err) - } - - settings.ClerkSettings = *clerkSettings - return settings, nil } diff --git a/backend/src/config/production.go b/backend/src/config/production.go index 12cf000a9..cb609bbbd 100644 --- a/backend/src/config/production.go +++ b/backend/src/config/production.go @@ -105,11 +105,6 @@ func readProd(v *viper.Viper) (*Settings, error) { return nil, fmt.Errorf("failed to read Resend settings: %w", err) } - clerkSettings, err := readClerkSettings() - if err != nil { - return nil, fmt.Errorf("failed to read Clerk settings: %w", err) - } - return &Settings{ Application: ApplicationSettings{ Port: uint16(portInt), @@ -136,6 +131,5 @@ func readProd(v *viper.Viper) (*Settings, error) { PineconeSettings: *pineconeSettings, OpenAISettings: *openAISettings, ResendSettings: *resendSettings, - ClerkSettings: *clerkSettings, }, nil } diff --git a/backend/src/controllers/auth.go b/backend/src/controllers/auth.go index d938bd59b..55801a49f 100644 --- a/backend/src/controllers/auth.go +++ b/backend/src/controllers/auth.go @@ -3,8 +3,6 @@ package controllers import ( "time" - "github.com/GenerateNU/sac/backend/src/auth" - "github.com/GenerateNU/sac/backend/src/config" "github.com/GenerateNU/sac/backend/src/errors" "github.com/GenerateNU/sac/backend/src/models" "github.com/GenerateNU/sac/backend/src/services" @@ -13,39 +11,11 @@ import ( ) type AuthController struct { - authService services.AuthServiceInterface - blacklist []string - AuthSettings config.AuthSettings + authService services.AuthServiceInterface } -func NewAuthController(authService services.AuthServiceInterface, authSettings config.AuthSettings) *AuthController { - return &AuthController{authService: authService, blacklist: []string{}, AuthSettings: authSettings} -} - -// Me godoc -// -// @Summary Retrieve the current user given an auth session -// @Description Returns the current user associated with an auth session -// @ID get-current-user -// @Tags auth -// @Produce json -// @Success 200 {object} models.User -// @Failure 400 {object} errors.Error -// @Failure 401 {object} errors.Error -// @Failure 404 {object} errors.Error -// @Failure 500 {object} errors.Error -// @Router /auth/me [get] -func (a *AuthController) Me(c *fiber.Ctx) error { - claims, err := auth.From(c) - if err != nil { - return err.FiberError(c) - } - user, err := a.authService.Me(claims.Issuer) - if err != nil { - return err.FiberError(c) - } - - return c.Status(fiber.StatusOK).JSON(user) +func NewAuthController(authService services.AuthServiceInterface) *AuthController { + return &AuthController{authService: authService} } // Login godoc @@ -57,7 +27,7 @@ func (a *AuthController) Me(c *fiber.Ctx) error { // @Accept json // @Produce json // @Param loginBody body models.LoginUserResponseBody true "Login Body" -// @Success 200 {object} utilities.SuccessResponse +// @Success 200 {object} models.User // @Failure 400 {object} errors.Error // @Failure 404 {object} errors.Error // @Failure 500 {object} errors.Error @@ -69,21 +39,25 @@ func (a *AuthController) Login(c *fiber.Ctx) error { return errors.FailedToParseRequestBody.FiberError(c) } - user, err := a.authService.Login(userBody) + user, tokens, err := a.authService.Login(userBody) if err != nil { return err.FiberError(c) } - accessToken, refreshToken, err := auth.CreateTokenPair(user.ID.String(), string(user.Role), a.AuthSettings) + err = a.authService.SetResponseTokens(c, tokens) if err != nil { - return errors.Unauthorized.FiberError(c) + return err.FiberError(c) } - // Set the tokens in the response - c.Cookie(auth.CreateCookie("access_token", *accessToken, time.Now().Add(time.Minute*time.Duration(a.AuthSettings.AccessTokenExpiry)))) - c.Cookie(auth.CreateCookie("refresh_token", *refreshToken, time.Now().Add(time.Hour*time.Duration(a.AuthSettings.RefreshTokenExpiry)))) + // c.Set("Authorization", fmt.Sprintf("Bearer %s", string(tokens.AccessToken))) + // c.Cookie(&fiber.Cookie{ + // Name: "refresh_token", + // Value: string(tokens.RefreshToken), + // Expires: time.Now().Add(time.Hour * time.Duration(a.AuthSettings.RefreshTokenExpiry)), + // HTTPOnly: true, + // }) - return utilities.FiberMessage(c, fiber.StatusOK, "success") + return c.Status(fiber.StatusOK).JSON(user) } // Refresh godoc @@ -98,30 +72,24 @@ func (a *AuthController) Login(c *fiber.Ctx) error { // @Failure 400 {object} errors.Error // @Failure 404 {object} errors.Error // @Failure 500 {object} errors.Error -// @Router /auth/refresh [get] +// @Router /auth/refresh [post] func (a *AuthController) Refresh(c *fiber.Ctx) error { - // Extract token values from cookies - refreshTokenValue := c.Cookies("refresh_token") + var refreshBody models.RefreshTokenRequestBody - // Extract id from refresh token - claims, err := auth.ExtractRefreshClaims(refreshTokenValue, a.AuthSettings.RefreshKey) - if err != nil { - return errors.Unauthorized.FiberError(c) + if err := c.BodyParser(&refreshBody); err != nil { + return errors.FailedToParseRequestBody.FiberError(c) } - role, err := a.authService.GetRole(claims.Issuer) + tokens, err := a.authService.Refresh(refreshBody.RefreshToken) if err != nil { - return errors.Unauthorized.FiberError(c) + return err.FiberError(c) } - accessToken, err := auth.RefreshAccessToken(refreshTokenValue, string(*role), a.AuthSettings.RefreshKey, a.AuthSettings.AccessTokenExpiry, a.AuthSettings.AccessKey) + err = a.authService.SetResponseTokens(c, tokens) if err != nil { - return errors.Unauthorized.FiberError(c) + return err.FiberError(c) } - // Set the access token in the response - c.Cookie(auth.CreateCookie("access_token", *accessToken, time.Now().Add(time.Minute*60))) - return utilities.FiberMessage(c, fiber.StatusOK, "success") } @@ -134,50 +102,24 @@ func (a *AuthController) Refresh(c *fiber.Ctx) error { // @Accept json // @Produce json // @Success 200 {object} utilities.SuccessResponse -// @Router /auth/logout [get] +// @Router /auth/logout [post] func (a *AuthController) Logout(c *fiber.Ctx) error { // Extract token values from cookies - accessTokenValue := c.Cookies("access_token") - refreshTokenValue := c.Cookies("refresh_token") + c.Get("Authorization") + c.Cookies("refresh_token") // TODO: Redis - a.blacklist = append(a.blacklist, accessTokenValue) - a.blacklist = append(a.blacklist, refreshTokenValue) + // a.blacklist = append(a.blacklist, accessTokenValue) + // a.blacklist = append(a.blacklist, refreshTokenValue) // Expire and clear the cookies - c.Cookie(auth.ExpireCookie("access_token")) - c.Cookie(auth.ExpireCookie("refresh_token")) - - return utilities.FiberMessage(c, fiber.StatusOK, "success") -} - -// UpdatePassword godoc -// -// @Summary Updates a user's password -// @Description Updates a user's password -// @ID update-password -// @Tags auth -// @Accept json -// @Produce json -// @Param userID path string true "User ID" -// @Param userBody body models.UpdatePasswordRequestBody true "User Body" -// @Success 200 {object} utilities.SuccessResponse -// @Failure 400 {object} errors.Error -// @Failure 401 {object} errors.Error -// @Failure 404 {object} errors.Error -// @Failure 429 {object} errors.Error -// @Failure 500 {object} errors.Error -// @Router /auth/update-password/{userID} [post] -func (a *AuthController) UpdatePassword(c *fiber.Ctx) error { - var userBody models.UpdatePasswordRequestBody - - if err := c.BodyParser(&userBody); err != nil { - return errors.FailedToParseRequestBody.FiberError(c) - } - - if err := a.authService.UpdatePassword(c.Params("userID"), userBody); err != nil { - return err.FiberError(c) - } + c.Set("Authorization", "") + c.Cookie(&fiber.Cookie{ + Name: "refresh_token", + Value: "", + Expires: time.Now().Add(-time.Hour), + HTTPOnly: true, + }) return utilities.FiberMessage(c, fiber.StatusOK, "success") } @@ -190,20 +132,20 @@ func (a *AuthController) UpdatePassword(c *fiber.Ctx) error { // @Tags auth // @Accept json // @Produce json -// @Param userBody body models.PasswordResetRequestBody true "User Body" +// @Param email body string true "Email" // @Success 200 {object} utilities.SuccessResponse // @Failure 400 {object} errors.Error // @Failure 429 {object} errors.Error // @Failure 500 {object} errors.Error // @Router /auth/forgot-password [post] func (a *AuthController) ForgotPassword(c *fiber.Ctx) error { - var userBody models.PasswordResetRequestBody + var emailBody models.EmailRequestBody - if err := c.BodyParser(&userBody); err != nil { + if err := c.BodyParser(&emailBody); err != nil { return errors.FailedToParseRequestBody.FiberError(c) } - if err := a.authService.ForgotPassword(userBody); err != nil { + if err := a.authService.ForgotPassword(emailBody.Email); err != nil { return err.FiberError(c) } @@ -218,7 +160,8 @@ func (a *AuthController) ForgotPassword(c *fiber.Ctx) error { // @Tags auth // @Accept json // @Produce json -// @Param tokenBody body models.VerifyPasswordResetTokenRequestBody true "Token Body" +// @Param tokenBody body models.VerifyPasswordResetTokenRequestBody true "Password Reset Token Body" +// @Security Bearer // @Success 200 {object} utilities.SuccessResponse // @Failure 400 {object} errors.Error // @Failure 429 {object} errors.Error @@ -246,14 +189,20 @@ func (a *AuthController) VerifyPasswordResetToken(c *fiber.Ctx) error { // @Tags auth // @Accept json // @Produce json -// @Param userID path string true "User ID" +// @Param email body string true "Email" // @Success 200 {object} utilities.SuccessResponse // @Failure 400 {object} errors.Error // @Failure 429 {object} errors.Error // @Failure 500 {object} errors.Error -// @Router /auth/send-code/{userID} [post] +// @Router /auth/send-code [post] func (a *AuthController) SendCode(c *fiber.Ctx) error { - if err := a.authService.SendCode(c.Params("userID")); err != nil { + var emailBody models.EmailRequestBody + + if err := c.BodyParser(&emailBody); err != nil { + return errors.FailedToParseRequestBody.FiberError(c) + } + + if err := a.authService.SendCode(emailBody.Email); err != nil { return err.FiberError(c) } @@ -268,7 +217,7 @@ func (a *AuthController) SendCode(c *fiber.Ctx) error { // @Tags auth // @Accept json // @Produce json -// @Param tokenBody body models.VerifyEmailRequestBody true +// @Param tokenBody body models.VerifyEmailRequestBody true "Email Verification Token Body" // @Success 200 {object} utilities.SuccessResponse // @Failure 400 {object} errors.Error // @Failure 429 {object} errors.Error diff --git a/backend/src/controllers/user.go b/backend/src/controllers/user.go index 68a514994..5b5a2137b 100644 --- a/backend/src/controllers/user.go +++ b/backend/src/controllers/user.go @@ -3,9 +3,11 @@ package controllers import ( "strconv" + "github.com/GenerateNU/sac/backend/src/auth" "github.com/GenerateNU/sac/backend/src/errors" "github.com/GenerateNU/sac/backend/src/models" "github.com/GenerateNU/sac/backend/src/services" + "github.com/GenerateNU/sac/backend/src/utilities" "github.com/gofiber/fiber/v2" ) @@ -41,7 +43,7 @@ func (u *UserController) CreateUser(c *fiber.Ctx) error { return errors.FailedToParseRequestBody.FiberError(c) } - user, err := u.userService.CreateUser(userBody) + user, err := u.userService.CreateUser(c, userBody) if err != nil { return err.FiberError(c) } @@ -75,6 +77,35 @@ func (u *UserController) GetUsers(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(&categories) } +// Me godoc +// +// @Summary Retrieves the currently authenticated user +// @Description Retrieves the currently authenticated user +// @ID get-me +// @Tags auth +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} models.User +// @Failure 400 {object} errors.Error +// @Failure 401 {object} errors.Error +// @Failure 404 {object} errors.Error +// @Failure 500 {object} errors.Error +// @Router /auth/me [get] +func (u *UserController) GetMe(c *fiber.Ctx) error { + claims, err := auth.From(c) + if err != nil { + return err.FiberError(c) + } + + user, err := u.userService.GetMe(claims.Issuer) + if err != nil { + return err.FiberError(c) + } + + return c.Status(fiber.StatusOK).JSON(user) +} + // GetUser godoc // // @Summary Retrieve a user @@ -126,10 +157,40 @@ func (u *UserController) UpdateUser(c *fiber.Ctx) error { return err.FiberError(c) } - // Return the updated user details return c.Status(fiber.StatusOK).JSON(updatedUser) } +// UpdatePassword godoc +// +// @Summary Update a user's password +// @Description Updates a user's password +// @ID update-password +// @Tags user +// @Accept json +// @Produce json +// @Param userID path string true "User ID" +// @Param passwordBody body models.UpdatePasswordRequestBody true "Password Body" +// @Success 200 {string} utilities.SuccessResponse +// @Failure 400 {object} errors.Error +// @Failure 401 {object} errors.Error +// @Failure 404 {object} errors.Error +// @Failure 500 {object} errors.Error +// @Router /users/{userID}/password [patch] +func (u *UserController) UpdatePassword(c *fiber.Ctx) error { + var passwordBody models.UpdatePasswordRequestBody + + if err := c.BodyParser(&passwordBody); err != nil { + return errors.FailedToParseRequestBody.FiberError(c) + } + + err := u.userService.UpdatePassword(c.Params("userID"), passwordBody) + if err != nil { + return err.FiberError(c) + } + + return utilities.FiberMessage(c, fiber.StatusOK, "success") +} + // DeleteUser godoc // // @Summary Delete a user diff --git a/backend/src/docs/docs.go b/backend/src/docs/docs.go index d16085a28..d7b198727 100644 --- a/backend/src/docs/docs.go +++ b/backend/src/docs/docs.go @@ -9,15 +9,65 @@ const docTemplate = `{ "info": { "description": "{{escape .Description}}", "title": "{{.Title}}", - "contact": { - "name": "David Oduneye and Garrett Ladley", - "email": "generatesac@gmail.com" - }, + "contact": {}, "version": "{{.Version}}" }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/auth/forgot-password": { + "post": { + "description": "Generates a password reset token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Generates a password reset token", + "operationId": "forgot-password", + "parameters": [ + { + "description": "Email", + "name": "email", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/utilities.SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/errors.Error" + } + } + } + } + }, "/auth/login": { "post": { "description": "Logs in a user", @@ -47,7 +97,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/utilities.SuccessResponse" + "$ref": "#/definitions/models.User" } }, "400": { @@ -72,7 +122,7 @@ const docTemplate = `{ } }, "/auth/logout": { - "get": { + "post": { "description": "Logs out a user", "consumes": [ "application/json" @@ -97,15 +147,23 @@ const docTemplate = `{ }, "/auth/me": { "get": { - "description": "Returns the current user associated with an auth session", + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves the currently authenticated user", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "auth" ], - "summary": "Retrieve the current user given an auth session", - "operationId": "get-current-user", + "summary": "Retrieves the currently authenticated user", + "operationId": "get-me", "responses": { "200": { "description": "OK", @@ -141,7 +199,7 @@ const docTemplate = `{ } }, "/auth/refresh": { - "get": { + "post": { "description": "Refreshes a user's access token", "consumes": [ "application/json" @@ -182,9 +240,9 @@ const docTemplate = `{ } } }, - "/auth/update-password/{userID}": { + "/auth/send-code": { "post": { - "description": "Updates a user's password", + "description": "Sends a verification code", "consumes": [ "application/json" ], @@ -194,23 +252,69 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Updates a user's password", - "operationId": "update-password", + "summary": "Sends a verification code", + "operationId": "send-verification-code", "parameters": [ { - "type": "string", - "description": "User ID", - "name": "userID", - "in": "path", - "required": true + "description": "Email", + "name": "email", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/utilities.SuccessResponse" + } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/errors.Error" + } + } + } + } + }, + "/auth/verify-email": { + "post": { + "description": "Verifies an email", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Verifies an email", + "operationId": "verify-email", + "parameters": [ { - "description": "User Body", - "name": "userBody", + "description": "Email Verification Token Body", + "name": "tokenBody", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.UpdatePasswordRequestBody" + "$ref": "#/definitions/models.VerifyEmailRequestBody" } } ], @@ -227,14 +331,60 @@ const docTemplate = `{ "$ref": "#/definitions/errors.Error" } }, - "401": { - "description": "Unauthorized", + "429": { + "description": "Too Many Requests", "schema": { "$ref": "#/definitions/errors.Error" } }, - "404": { - "description": "Not Found", + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/errors.Error" + } + } + } + } + }, + "/auth/verify-reset": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Verifies a password reset token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Verifies a password reset token", + "operationId": "verify-password-reset-token", + "parameters": [ + { + "description": "Password Reset Token Body", + "name": "tokenBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.VerifyPasswordResetTokenRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/utilities.SuccessResponse" + } + }, + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/errors.Error" } @@ -358,6 +508,12 @@ const docTemplate = `{ "type": "string" } }, + "409": { + "description": "Conflict", + "schema": { + "type": "string" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -1907,7 +2063,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.CreateSeriesRequestBody" + "$ref": "#/definitions/models.UpdateEventRequestBody" } } ], @@ -2059,7 +2215,7 @@ const docTemplate = `{ } }, "patch": { - "description": "Updates a series by ID", + "description": "Updates a series by event ID", "consumes": [ "application/json" ], @@ -2069,8 +2225,8 @@ const docTemplate = `{ "tags": [ "event" ], - "summary": "Update a series by ID", - "operationId": "update-series-by-id", + "summary": "Update a series by event ID", + "operationId": "update-series-by-event-id", "parameters": [ { "type": "string", @@ -2079,13 +2235,6 @@ const docTemplate = `{ "in": "path", "required": true }, - { - "type": "string", - "description": "Series ID", - "name": "seriesID", - "in": "path", - "required": true - }, { "description": "Series Body", "name": "seriesBody", @@ -2513,6 +2662,12 @@ const docTemplate = `{ "$ref": "#/definitions/errors.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -3034,6 +3189,72 @@ const docTemplate = `{ } } }, + "/users/{userID}/password": { + "patch": { + "description": "Updates a user's password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update a user's password", + "operationId": "update-password", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "userID", + "in": "path", + "required": true + }, + { + "description": "Password Body", + "name": "passwordBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UpdatePasswordRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/errors.Error" + } + } + } + } + }, "/users/{userID}/tags/": { "get": { "description": "Retrieves all tags associated with a user", @@ -3110,15 +3331,6 @@ const docTemplate = `{ "name": "userID", "in": "path", "required": true - }, - { - "description": "User Tags Body", - "name": "userTagsBody", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.CreateUserTagsBody" - } } ], "responses": { @@ -3229,9 +3441,8 @@ const docTemplate = `{ "example": "2023-09-20T16:34:50Z" }, "description": { - "description": "MongoDB URL", - "type": "string", - "maxLength": 255 + "description": "FIXME: make description a mongodb url again\nDescription string ` + "`" + `gorm:\"type:varchar(255)\" json:\"description\" validate:\"required,http_url,mongo_url,max=255\"` + "`" + ` // MongoDB URL", + "type": "string" }, "id": { "type": "string", @@ -3510,6 +3721,10 @@ const docTemplate = `{ }, "event_type": { "maxLength": 255, + "enum": [ + "open", + "membersOnly" + ], "allOf": [ { "$ref": "#/definitions/models.EventType" @@ -3546,6 +3761,14 @@ const docTemplate = `{ }, "models.CreateSeriesRequestBody": { "type": "object", + "required": [ + "day_of_month", + "day_of_week", + "max_occurrences", + "recurring_type", + "separation_count", + "week_of_month" + ], "properties": { "day_of_month": { "type": "integer", @@ -3563,6 +3786,11 @@ const docTemplate = `{ }, "recurring_type": { "maxLength": 255, + "enum": [ + "daily", + "weekly", + "monthly" + ], "allOf": [ { "$ref": "#/definitions/models.RecurringType" @@ -3599,33 +3827,12 @@ const docTemplate = `{ "models.CreateUserRequestBody": { "type": "object", "required": [ - "college", "email", "first_name", "last_name", - "nuid", - "password", - "year" + "password" ], "properties": { - "college": { - "enum": [ - "CAMD", - "DMSB", - "KCCS", - "CE", - "BCHS", - "SL", - "CPS", - "CS", - "CSSH" - ], - "allOf": [ - { - "$ref": "#/definitions/models.College" - } - ] - }, "email": { "type": "string", "maxLength": 255 @@ -3638,20 +3845,10 @@ const docTemplate = `{ "type": "string", "maxLength": 255 }, - "nuid": { - "type": "string" - }, "password": { - "type": "string" - }, - "year": { - "maximum": 6, - "minimum": 1, - "allOf": [ - { - "$ref": "#/definitions/models.Year" - } - ] + "type": "string", + "maxLength": 255, + "minLength": 8 } } }, @@ -3694,6 +3891,10 @@ const docTemplate = `{ }, "event_type": { "maxLength": 255, + "enum": [ + "open", + "membersOnly" + ], "allOf": [ { "$ref": "#/definitions/models.EventType" @@ -3742,7 +3943,8 @@ const docTemplate = `{ "models.LoginUserResponseBody": { "type": "object", "required": [ - "email" + "email", + "password" ], "properties": { "email": { @@ -3973,6 +4175,45 @@ const docTemplate = `{ } } }, + "models.UpdateEventRequestBody": { + "type": "object", + "properties": { + "content": { + "type": "string", + "maxLength": 255 + }, + "end_time": { + "type": "string" + }, + "event_type": { + "maxLength": 255, + "enum": [ + "open", + "membersOnly" + ], + "allOf": [ + { + "$ref": "#/definitions/models.EventType" + } + ] + }, + "location": { + "type": "string", + "maxLength": 255 + }, + "name": { + "type": "string", + "maxLength": 255 + }, + "preview": { + "type": "string", + "maxLength": 255 + }, + "start_time": { + "type": "string" + } + } + }, "models.UpdatePasswordRequestBody": { "type": "object", "required": [ @@ -4005,12 +4246,20 @@ const docTemplate = `{ "maximum": 7, "minimum": 1 }, + "event_details": { + "$ref": "#/definitions/models.UpdateEventRequestBody" + }, "max_occurrences": { "type": "integer", "minimum": 2 }, "recurring_type": { "maxLength": 255, + "enum": [ + "daily", + "weekly", + "monthly" + ], "allOf": [ { "$ref": "#/definitions/models.RecurringType" @@ -4061,10 +4310,6 @@ const docTemplate = `{ } ] }, - "email": { - "type": "string", - "maxLength": 255 - }, "first_name": { "type": "string", "maxLength": 255 @@ -4073,9 +4318,6 @@ const docTemplate = `{ "type": "string", "maxLength": 255 }, - "nuid": { - "type": "string" - }, "year": { "maximum": 6, "minimum": 1, @@ -4123,6 +4365,9 @@ const docTemplate = `{ "type": "string", "example": "123e4567-e89b-12d3-a456-426614174000" }, + "is_verified": { + "type": "boolean" + }, "last_name": { "type": "string", "maxLength": 255 @@ -4152,6 +4397,42 @@ const docTemplate = `{ } } }, + "models.VerifyEmailRequestBody": { + "type": "object", + "required": [ + "email", + "token" + ], + "properties": { + "email": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, + "models.VerifyPasswordResetTokenRequestBody": { + "type": "object", + "required": [ + "new_password", + "token", + "verify_new_password" + ], + "properties": { + "new_password": { + "type": "string", + "minLength": 8 + }, + "token": { + "type": "string" + }, + "verify_new_password": { + "type": "string", + "minLength": 8 + } + } + }, "models.Year": { "type": "integer", "enum": [ @@ -4184,12 +4465,12 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "1.0", - Host: "127.0.0.1:8080", - BasePath: "/api/v1", + Version: "", + Host: "", + BasePath: "", Schemes: []string{}, - Title: "SAC API", - Description: "Backend Server for SAC App", + Title: "", + Description: "", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/backend/src/docs/swagger.json b/backend/src/docs/swagger.json index 4919338f5..6e34f63fb 100644 --- a/backend/src/docs/swagger.json +++ b/backend/src/docs/swagger.json @@ -1,17 +1,62 @@ { "swagger": "2.0", "info": { - "description": "Backend Server for SAC App", - "title": "SAC API", - "contact": { - "name": "David Oduneye and Garrett Ladley", - "email": "generatesac@gmail.com" - }, - "version": "1.0" + "contact": {} }, - "host": "127.0.0.1:8080", - "basePath": "/api/v1", "paths": { + "/auth/forgot-password": { + "post": { + "description": "Generates a password reset token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Generates a password reset token", + "operationId": "forgot-password", + "parameters": [ + { + "description": "Email", + "name": "email", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/utilities.SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/errors.Error" + } + } + } + } + }, "/auth/login": { "post": { "description": "Logs in a user", @@ -41,7 +86,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/utilities.SuccessResponse" + "$ref": "#/definitions/models.User" } }, "400": { @@ -66,7 +111,7 @@ } }, "/auth/logout": { - "get": { + "post": { "description": "Logs out a user", "consumes": [ "application/json" @@ -91,15 +136,23 @@ }, "/auth/me": { "get": { - "description": "Returns the current user associated with an auth session", + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves the currently authenticated user", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "auth" ], - "summary": "Retrieve the current user given an auth session", - "operationId": "get-current-user", + "summary": "Retrieves the currently authenticated user", + "operationId": "get-me", "responses": { "200": { "description": "OK", @@ -135,7 +188,7 @@ } }, "/auth/refresh": { - "get": { + "post": { "description": "Refreshes a user's access token", "consumes": [ "application/json" @@ -176,9 +229,9 @@ } } }, - "/auth/update-password/{userID}": { + "/auth/send-code": { "post": { - "description": "Updates a user's password", + "description": "Sends a verification code", "consumes": [ "application/json" ], @@ -188,23 +241,69 @@ "tags": [ "auth" ], - "summary": "Updates a user's password", - "operationId": "update-password", + "summary": "Sends a verification code", + "operationId": "send-verification-code", "parameters": [ { - "type": "string", - "description": "User ID", - "name": "userID", - "in": "path", - "required": true + "description": "Email", + "name": "email", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/utilities.SuccessResponse" + } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/errors.Error" + } + } + } + } + }, + "/auth/verify-email": { + "post": { + "description": "Verifies an email", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Verifies an email", + "operationId": "verify-email", + "parameters": [ { - "description": "User Body", - "name": "userBody", + "description": "Email Verification Token Body", + "name": "tokenBody", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.UpdatePasswordRequestBody" + "$ref": "#/definitions/models.VerifyEmailRequestBody" } } ], @@ -221,14 +320,60 @@ "$ref": "#/definitions/errors.Error" } }, - "401": { - "description": "Unauthorized", + "429": { + "description": "Too Many Requests", "schema": { "$ref": "#/definitions/errors.Error" } }, - "404": { - "description": "Not Found", + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/errors.Error" + } + } + } + } + }, + "/auth/verify-reset": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Verifies a password reset token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Verifies a password reset token", + "operationId": "verify-password-reset-token", + "parameters": [ + { + "description": "Password Reset Token Body", + "name": "tokenBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.VerifyPasswordResetTokenRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/utilities.SuccessResponse" + } + }, + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/errors.Error" } @@ -352,6 +497,12 @@ "type": "string" } }, + "409": { + "description": "Conflict", + "schema": { + "type": "string" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -1901,7 +2052,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.CreateSeriesRequestBody" + "$ref": "#/definitions/models.UpdateEventRequestBody" } } ], @@ -2053,7 +2204,7 @@ } }, "patch": { - "description": "Updates a series by ID", + "description": "Updates a series by event ID", "consumes": [ "application/json" ], @@ -2063,8 +2214,8 @@ "tags": [ "event" ], - "summary": "Update a series by ID", - "operationId": "update-series-by-id", + "summary": "Update a series by event ID", + "operationId": "update-series-by-event-id", "parameters": [ { "type": "string", @@ -2073,13 +2224,6 @@ "in": "path", "required": true }, - { - "type": "string", - "description": "Series ID", - "name": "seriesID", - "in": "path", - "required": true - }, { "description": "Series Body", "name": "seriesBody", @@ -2507,6 +2651,12 @@ "$ref": "#/definitions/errors.Error" } }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -3028,6 +3178,72 @@ } } }, + "/users/{userID}/password": { + "patch": { + "description": "Updates a user's password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update a user's password", + "operationId": "update-password", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "userID", + "in": "path", + "required": true + }, + { + "description": "Password Body", + "name": "passwordBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UpdatePasswordRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/errors.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/errors.Error" + } + } + } + } + }, "/users/{userID}/tags/": { "get": { "description": "Retrieves all tags associated with a user", @@ -3104,15 +3320,6 @@ "name": "userID", "in": "path", "required": true - }, - { - "description": "User Tags Body", - "name": "userTagsBody", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.CreateUserTagsBody" - } } ], "responses": { @@ -3223,9 +3430,8 @@ "example": "2023-09-20T16:34:50Z" }, "description": { - "description": "MongoDB URL", - "type": "string", - "maxLength": 255 + "description": "FIXME: make description a mongodb url again\nDescription string `gorm:\"type:varchar(255)\" json:\"description\" validate:\"required,http_url,mongo_url,max=255\"` // MongoDB URL", + "type": "string" }, "id": { "type": "string", @@ -3504,6 +3710,10 @@ }, "event_type": { "maxLength": 255, + "enum": [ + "open", + "membersOnly" + ], "allOf": [ { "$ref": "#/definitions/models.EventType" @@ -3540,6 +3750,14 @@ }, "models.CreateSeriesRequestBody": { "type": "object", + "required": [ + "day_of_month", + "day_of_week", + "max_occurrences", + "recurring_type", + "separation_count", + "week_of_month" + ], "properties": { "day_of_month": { "type": "integer", @@ -3557,6 +3775,11 @@ }, "recurring_type": { "maxLength": 255, + "enum": [ + "daily", + "weekly", + "monthly" + ], "allOf": [ { "$ref": "#/definitions/models.RecurringType" @@ -3593,33 +3816,12 @@ "models.CreateUserRequestBody": { "type": "object", "required": [ - "college", "email", "first_name", "last_name", - "nuid", - "password", - "year" + "password" ], "properties": { - "college": { - "enum": [ - "CAMD", - "DMSB", - "KCCS", - "CE", - "BCHS", - "SL", - "CPS", - "CS", - "CSSH" - ], - "allOf": [ - { - "$ref": "#/definitions/models.College" - } - ] - }, "email": { "type": "string", "maxLength": 255 @@ -3632,20 +3834,10 @@ "type": "string", "maxLength": 255 }, - "nuid": { - "type": "string" - }, "password": { - "type": "string" - }, - "year": { - "maximum": 6, - "minimum": 1, - "allOf": [ - { - "$ref": "#/definitions/models.Year" - } - ] + "type": "string", + "maxLength": 255, + "minLength": 8 } } }, @@ -3688,6 +3880,10 @@ }, "event_type": { "maxLength": 255, + "enum": [ + "open", + "membersOnly" + ], "allOf": [ { "$ref": "#/definitions/models.EventType" @@ -3736,7 +3932,8 @@ "models.LoginUserResponseBody": { "type": "object", "required": [ - "email" + "email", + "password" ], "properties": { "email": { @@ -3967,6 +4164,45 @@ } } }, + "models.UpdateEventRequestBody": { + "type": "object", + "properties": { + "content": { + "type": "string", + "maxLength": 255 + }, + "end_time": { + "type": "string" + }, + "event_type": { + "maxLength": 255, + "enum": [ + "open", + "membersOnly" + ], + "allOf": [ + { + "$ref": "#/definitions/models.EventType" + } + ] + }, + "location": { + "type": "string", + "maxLength": 255 + }, + "name": { + "type": "string", + "maxLength": 255 + }, + "preview": { + "type": "string", + "maxLength": 255 + }, + "start_time": { + "type": "string" + } + } + }, "models.UpdatePasswordRequestBody": { "type": "object", "required": [ @@ -3999,12 +4235,20 @@ "maximum": 7, "minimum": 1 }, + "event_details": { + "$ref": "#/definitions/models.UpdateEventRequestBody" + }, "max_occurrences": { "type": "integer", "minimum": 2 }, "recurring_type": { "maxLength": 255, + "enum": [ + "daily", + "weekly", + "monthly" + ], "allOf": [ { "$ref": "#/definitions/models.RecurringType" @@ -4055,10 +4299,6 @@ } ] }, - "email": { - "type": "string", - "maxLength": 255 - }, "first_name": { "type": "string", "maxLength": 255 @@ -4067,9 +4307,6 @@ "type": "string", "maxLength": 255 }, - "nuid": { - "type": "string" - }, "year": { "maximum": 6, "minimum": 1, @@ -4117,6 +4354,9 @@ "type": "string", "example": "123e4567-e89b-12d3-a456-426614174000" }, + "is_verified": { + "type": "boolean" + }, "last_name": { "type": "string", "maxLength": 255 @@ -4146,6 +4386,42 @@ } } }, + "models.VerifyEmailRequestBody": { + "type": "object", + "required": [ + "email", + "token" + ], + "properties": { + "email": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, + "models.VerifyPasswordResetTokenRequestBody": { + "type": "object", + "required": [ + "new_password", + "token", + "verify_new_password" + ], + "properties": { + "new_password": { + "type": "string", + "minLength": 8 + }, + "token": { + "type": "string" + }, + "verify_new_password": { + "type": "string", + "minLength": 8 + } + } + }, "models.Year": { "type": "integer", "enum": [ diff --git a/backend/src/docs/swagger.yaml b/backend/src/docs/swagger.yaml index 297340a4c..a6e263ef5 100644 --- a/backend/src/docs/swagger.yaml +++ b/backend/src/docs/swagger.yaml @@ -1,4 +1,3 @@ -basePath: /api/v1 definitions: errors.Error: properties: @@ -41,8 +40,9 @@ definitions: example: "2023-09-20T16:34:50Z" type: string description: - description: MongoDB URL - maxLength: 255 + description: |- + FIXME: make description a mongodb url again + Description string `gorm:"type:varchar(255)" json:"description" validate:"required,http_url,mongo_url,max=255"` // MongoDB URL type: string id: example: 123e4567-e89b-12d3-a456-426614174000 @@ -254,6 +254,9 @@ definitions: event_type: allOf: - $ref: '#/definitions/models.EventType' + enum: + - open + - membersOnly maxLength: 255 is_recurring: type: boolean @@ -298,6 +301,10 @@ definitions: recurring_type: allOf: - $ref: '#/definitions/models.RecurringType' + enum: + - daily + - weekly + - monthly maxLength: 255 separation_count: minimum: 0 @@ -306,6 +313,13 @@ definitions: maximum: 5 minimum: 1 type: integer + required: + - day_of_month + - day_of_week + - max_occurrences + - recurring_type + - separation_count + - week_of_month type: object models.CreateTagRequestBody: properties: @@ -320,19 +334,6 @@ definitions: type: object models.CreateUserRequestBody: properties: - college: - allOf: - - $ref: '#/definitions/models.College' - enum: - - CAMD - - DMSB - - KCCS - - CE - - BCHS - - SL - - CPS - - CS - - CSSH email: maxLength: 255 type: string @@ -342,23 +343,15 @@ definitions: last_name: maxLength: 255 type: string - nuid: - type: string password: + maxLength: 255 + minLength: 8 type: string - year: - allOf: - - $ref: '#/definitions/models.Year' - maximum: 6 - minimum: 1 required: - - college - email - first_name - last_name - - nuid - password - - year type: object models.CreateUserTagsBody: properties: @@ -382,6 +375,9 @@ definitions: event_type: allOf: - $ref: '#/definitions/models.EventType' + enum: + - open + - membersOnly maxLength: 255 id: example: 123e4567-e89b-12d3-a456-426614174000 @@ -429,6 +425,7 @@ definitions: type: string required: - email + - password type: object models.PutContactRequestBody: properties: @@ -587,6 +584,32 @@ definitions: - recruitment_cycle - recruitment_type type: object + models.UpdateEventRequestBody: + properties: + content: + maxLength: 255 + type: string + end_time: + type: string + event_type: + allOf: + - $ref: '#/definitions/models.EventType' + enum: + - open + - membersOnly + maxLength: 255 + location: + maxLength: 255 + type: string + name: + maxLength: 255 + type: string + preview: + maxLength: 255 + type: string + start_time: + type: string + type: object models.UpdatePasswordRequestBody: properties: new_password: @@ -611,12 +634,18 @@ definitions: maximum: 7 minimum: 1 type: integer + event_details: + $ref: '#/definitions/models.UpdateEventRequestBody' max_occurrences: minimum: 2 type: integer recurring_type: allOf: - $ref: '#/definitions/models.RecurringType' + enum: + - daily + - weekly + - monthly maxLength: 255 separation_count: minimum: 0 @@ -649,17 +678,12 @@ definitions: - CPS - CS - CSSH - email: - maxLength: 255 - type: string first_name: maxLength: 255 type: string last_name: maxLength: 255 type: string - nuid: - type: string year: allOf: - $ref: '#/definitions/models.Year' @@ -684,6 +708,8 @@ definitions: id: example: 123e4567-e89b-12d3-a456-426614174000 type: string + is_verified: + type: boolean last_name: maxLength: 255 type: string @@ -711,6 +737,31 @@ definitions: - role - year type: object + models.VerifyEmailRequestBody: + properties: + email: + type: string + token: + type: string + required: + - email + - token + type: object + models.VerifyPasswordResetTokenRequestBody: + properties: + new_password: + minLength: 8 + type: string + token: + type: string + verify_new_password: + minLength: 8 + type: string + required: + - new_password + - token + - verify_new_password + type: object models.Year: enum: - 1 @@ -732,15 +783,44 @@ definitions: message: type: string type: object -host: 127.0.0.1:8080 info: - contact: - email: generatesac@gmail.com - name: David Oduneye and Garrett Ladley - description: Backend Server for SAC App - title: SAC API - version: "1.0" + contact: {} paths: + /auth/forgot-password: + post: + consumes: + - application/json + description: Generates a password reset token + operationId: forgot-password + parameters: + - description: Email + in: body + name: email + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/utilities.SuccessResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/errors.Error' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/errors.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/errors.Error' + summary: Generates a password reset token + tags: + - auth /auth/login: post: consumes: @@ -760,7 +840,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/utilities.SuccessResponse' + $ref: '#/definitions/models.User' "400": description: Bad Request schema: @@ -777,7 +857,7 @@ paths: tags: - auth /auth/logout: - get: + post: consumes: - application/json description: Logs out a user @@ -794,8 +874,10 @@ paths: - auth /auth/me: get: - description: Returns the current user associated with an auth session - operationId: get-current-user + consumes: + - application/json + description: Retrieves the currently authenticated user + operationId: get-me produces: - application/json responses: @@ -819,11 +901,13 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/errors.Error' - summary: Retrieve the current user given an auth session + security: + - Bearer: [] + summary: Retrieves the currently authenticated user tags: - auth /auth/refresh: - get: + post: consumes: - application/json description: Refreshes a user's access token @@ -850,24 +934,54 @@ paths: summary: Refreshes a user's access token tags: - auth - /auth/update-password/{userID}: + /auth/send-code: post: consumes: - application/json - description: Updates a user's password - operationId: update-password + description: Sends a verification code + operationId: send-verification-code parameters: - - description: User ID - in: path - name: userID + - description: Email + in: body + name: email required: true - type: string - - description: User Body + schema: + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/utilities.SuccessResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/errors.Error' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/errors.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/errors.Error' + summary: Sends a verification code + tags: + - auth + /auth/verify-email: + post: + consumes: + - application/json + description: Verifies an email + operationId: verify-email + parameters: + - description: Email Verification Token Body in: body - name: userBody + name: tokenBody required: true schema: - $ref: '#/definitions/models.UpdatePasswordRequestBody' + $ref: '#/definitions/models.VerifyEmailRequestBody' produces: - application/json responses: @@ -879,12 +993,39 @@ paths: description: Bad Request schema: $ref: '#/definitions/errors.Error' - "401": - description: Unauthorized + "429": + description: Too Many Requests schema: $ref: '#/definitions/errors.Error' - "404": - description: Not Found + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/errors.Error' + summary: Verifies an email + tags: + - auth + /auth/verify-reset: + post: + consumes: + - application/json + description: Verifies a password reset token + operationId: verify-password-reset-token + parameters: + - description: Password Reset Token Body + in: body + name: tokenBody + required: true + schema: + $ref: '#/definitions/models.VerifyPasswordResetTokenRequestBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/utilities.SuccessResponse' + "400": + description: Bad Request schema: $ref: '#/definitions/errors.Error' "429": @@ -895,7 +1036,9 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/errors.Error' - summary: Updates a user's password + security: + - Bearer: [] + summary: Verifies a password reset token tags: - auth /categories/: @@ -966,6 +1109,10 @@ paths: description: Not Found schema: type: string + "409": + description: Conflict + schema: + type: string "500": description: Internal Server Error schema: @@ -2001,7 +2148,7 @@ paths: name: seriesBody required: true schema: - $ref: '#/definitions/models.CreateSeriesRequestBody' + $ref: '#/definitions/models.UpdateEventRequestBody' produces: - application/json responses: @@ -2108,19 +2255,14 @@ paths: patch: consumes: - application/json - description: Updates a series by ID - operationId: update-series-by-id + description: Updates a series by event ID + operationId: update-series-by-event-id parameters: - description: Event ID in: path name: eventID required: true type: string - - description: Series ID - in: path - name: seriesID - required: true - type: string - description: Series Body in: body name: seriesBody @@ -2150,7 +2292,7 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/errors.Error' - summary: Update a series by ID + summary: Update a series by event ID tags: - event /tags: @@ -2407,6 +2549,10 @@ paths: description: Not Found schema: $ref: '#/definitions/errors.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/errors.Error' "500": description: Internal Server Error schema: @@ -2758,6 +2904,50 @@ paths: summary: Join a club tags: - user-member + /users/{userID}/password: + patch: + consumes: + - application/json + description: Updates a user's password + operationId: update-password + parameters: + - description: User ID + in: path + name: userID + required: true + type: string + - description: Password Body + in: body + name: passwordBody + required: true + schema: + $ref: '#/definitions/models.UpdatePasswordRequestBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/errors.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/errors.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/errors.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/errors.Error' + summary: Update a user's password + tags: + - user /users/{userID}/tags/: get: description: Retrieves all tags associated with a user @@ -2807,12 +2997,6 @@ paths: name: userID required: true type: string - - description: User Tags Body - in: body - name: userTagsBody - required: true - schema: - $ref: '#/definitions/models.CreateUserTagsBody' produces: - application/json responses: diff --git a/backend/src/email/email.go b/backend/src/email/resend.go similarity index 52% rename from backend/src/email/email.go rename to backend/src/email/resend.go index 0152e5e02..f545ac79d 100644 --- a/backend/src/email/email.go +++ b/backend/src/email/resend.go @@ -6,40 +6,47 @@ import ( "github.com/GenerateNU/sac/backend/src/config" "github.com/GenerateNU/sac/backend/src/errors" + "github.com/afex/hystrix-go/hystrix" "github.com/resend/resend-go/v2" ) -type EmailServiceInterface interface { +type ResendClientInterface interface { SendPasswordResetEmail(name, email, token string) *errors.Error SendEmailVerification(email, code string) *errors.Error SendWelcomeEmail(name, email string) *errors.Error SendPasswordChangedEmail(name, email string) *errors.Error } -type EmailService struct { +type ResendClient struct { Client *resend.Client + Dev bool } -func NewEmailClient(settings config.ResendSettings) *EmailService { - return &EmailService{ +func NewResendClient(settings config.ResendSettings, dev bool) *ResendClient { + hystrix.ConfigureCommand("send-email", hystrix.CommandConfig{ + Timeout: 5000, + MaxConcurrentRequests: 100, + ErrorPercentThreshold: 25, + }) + + return &ResendClient{ Client: resend.NewClient(settings.APIKey.Expose()), + Dev: dev, } } -func (e *EmailService) SendPasswordResetEmail(name, email, token string) *errors.Error { - template, err := getTemplateString("password_reset") - if err != nil { - return &errors.FailedToGetTemplate - } - +func send(email, subject, html string, client *resend.Client) *errors.Error { params := &resend.SendEmailRequest{ - From: "onboarding@resend.dev", + From: "onboarding@resend.dev", // TODO: get domain To: []string{email}, - Subject: "Password Reset", - Html: fmt.Sprintf(*template, name, token), + Subject: subject, + Html: html, } - _, err = e.Client.Emails.Send(params) + err := hystrix.Do("send-email", func() error { + _, err := client.Emails.Send(params) + return err + }, nil) if err != nil { return &errors.FailedToSendEmail } @@ -47,67 +54,55 @@ func (e *EmailService) SendPasswordResetEmail(name, email, token string) *errors return nil } -func (e *EmailService) SendEmailVerification(email, code string) *errors.Error { - template, err := getTemplateString("email_verification") +func (r *ResendClient) SendPasswordResetEmail(name, email, token string) *errors.Error { + if r.Dev { + return nil + } + + template, err := getTemplateString("password_reset") if err != nil { return &errors.FailedToGetTemplate } - params := &resend.SendEmailRequest{ - From: "onboarding@resend.dev", - To: []string{email}, - Subject: "Email Verification", - Html: fmt.Sprintf(*template, code), + return send(email, "Password Reset", fmt.Sprintf(*template, name, token), r.Client) +} +func (r *ResendClient) SendEmailVerification(email, code string) *errors.Error { + if r.Dev { + return nil } - _, err = e.Client.Emails.Send(params) + template, err := getTemplateString("email_verification") if err != nil { - return &errors.FailedToSendEmail + return &errors.FailedToGetTemplate } - return nil + return send(email, "Email Verification", fmt.Sprintf(*template, code), r.Client) } -func (e *EmailService) SendWelcomeEmail(name, email string) *errors.Error { +func (r *ResendClient) SendWelcomeEmail(name, email string) *errors.Error { + if r.Dev { + return nil + } + template, err := getTemplateString("welcome") if err != nil { return &errors.FailedToGetTemplate } - params := &resend.SendEmailRequest{ - From: "onboarding@resend.dev", - To: []string{email}, - Subject: "Welcome to Resend", - Html: fmt.Sprintf(*template, name), - } + return send(email, "Welcome to Resend", fmt.Sprintf(*template, name), r.Client) +} - _, err = e.Client.Emails.Send(params) - if err != nil { - return &errors.FailedToSendEmail +func (r *ResendClient) SendPasswordChangedEmail(name, email string) *errors.Error { + if r.Dev { + return nil } - return nil -} - -func (e *EmailService) SendPasswordChangedEmail(name, email string) *errors.Error { template, err := getTemplateString("password_change_complete") if err != nil { return &errors.FailedToGetTemplate } - params := &resend.SendEmailRequest{ - From: "onboarding@resend.dev", - To: []string{email}, - Subject: "Password Changed", - Html: fmt.Sprintf(*template, name), - } - - _, err = e.Client.Emails.Send(params) - if err != nil { - return &errors.FailedToSendEmail - } - - return nil + return send(email, "Password Changed", fmt.Sprintf(*template, name), r.Client) } func getTemplateString(name string) (*string, error) { diff --git a/backend/src/errors/auth.go b/backend/src/errors/auth.go index 868f96cb6..57a6cc2d7 100644 --- a/backend/src/errors/auth.go +++ b/backend/src/errors/auth.go @@ -3,9 +3,17 @@ package errors import "github.com/gofiber/fiber/v2" var ( - PassedAuthenticateMiddlewareButNilClaims = Error{ + FailedToExtractClaims = Error{ StatusCode: fiber.StatusInternalServerError, - Message: "passed authenticate middleware but claims is nil", + Message: "failed to extract claims", + } + FailedToParseToken = Error{ + StatusCode: fiber.StatusInternalServerError, + Message: "failed to parse token", + } + InvalidTokenType = Error{ + StatusCode: fiber.StatusBadRequest, + Message: "invalid token type", } FailedToValidateUpdatePasswordBody = Error{ StatusCode: fiber.StatusBadRequest, @@ -31,10 +39,26 @@ var ( StatusCode: fiber.StatusInternalServerError, Message: "failed to get password reset token", } + FailedToGetToken = Error{ + StatusCode: fiber.StatusInternalServerError, + Message: "failed to get token", + } + FailedToDeleteToken = Error{ + StatusCode: fiber.StatusInternalServerError, + Message: "failed to delete token", + } + FailedToSaveToken = Error{ + StatusCode: fiber.StatusInternalServerError, + Message: "failed to save token", + } PasswordResetTokenNotFound = Error{ StatusCode: fiber.StatusNotFound, Message: "password reset token not found", } + TokenNotFound = Error{ + StatusCode: fiber.StatusNotFound, + Message: "token not found", + } EmailAlreadyVerified = Error{ StatusCode: fiber.StatusBadRequest, Message: "email already verified", diff --git a/backend/src/errors/common.go b/backend/src/errors/common.go index 60e45ffe4..59cf0b1a4 100644 --- a/backend/src/errors/common.go +++ b/backend/src/errors/common.go @@ -9,7 +9,7 @@ var ( } FailedToParseRequestBody = Error{ StatusCode: fiber.StatusBadRequest, - Message: "failed to parse request body", + Message: "request body is not valid", } FailedtoParseQueryParams = Error{ StatusCode: fiber.StatusBadRequest, diff --git a/backend/src/main.go b/backend/src/main.go index f860f14d2..1b5534e9f 100644 --- a/backend/src/main.go +++ b/backend/src/main.go @@ -58,7 +58,7 @@ func main() { if *onlySeedPinecone { openAi := search.NewOpenAIClient(config.OpenAISettings) - pinecone := search.NewPineconeClient(openAi, config.PineconeSettings) + pinecone := search.NewPineconeClient(openAi, config.PineconeSettings, true) err := pinecone.Seed(db) if err != nil { @@ -74,7 +74,7 @@ func main() { } openAi := search.NewOpenAIClient(config.OpenAISettings) - pinecone := search.NewPineconeClient(openAi, config.PineconeSettings) + pinecone := search.NewPineconeClient(openAi, config.PineconeSettings, true) app := server.Init(db, pinecone, *config) diff --git a/backend/src/middleware/auth.go b/backend/src/middleware/auth.go index 6c5a52e88..5539bd07a 100644 --- a/backend/src/middleware/auth.go +++ b/backend/src/middleware/auth.go @@ -3,26 +3,28 @@ package middleware import ( "fmt" "slices" + "strings" "time" "github.com/GenerateNU/sac/backend/src/auth" "github.com/GenerateNU/sac/backend/src/errors" "github.com/GenerateNU/sac/backend/src/models" + "github.com/golang-jwt/jwt" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/limiter" ) -func getExcludedPaths() []string { - return []string{ - "/api/v1/auth/login", - "/api/v1/auth/refresh", - "/api/v1/users/", - "/api/v1/auth/logout", - "/api/v1/auth/forgot-password", - "/api/v1/auth/send-code/", - "/api/v1/auth/verify-email", - "/api/v1/auth/verify-reset", +func getExcludedPaths() []map[string]string { + return []map[string]string{ + {"/api/v1/auth/login": "POST"}, + {"/api/v1/auth/refresh": "POST"}, + {"/api/v1/users/": "POST"}, + {"/api/v1/auth/logout": "POST"}, + {"/api/v1/auth/forgot-password": "POST"}, + {"/api/v1/auth/send-code/*": "POST"}, + {"/api/v1/auth/verify-email": "POST"}, + {"/api/v1/auth/verify-reset": "POST"}, } } @@ -38,24 +40,56 @@ func (m *AuthMiddlewareService) IsSuper(c *fiber.Ctx) bool { return claims.Role == string(models.Super) } +func GetAuthroizationToken(c *fiber.Ctx) *string { + accessToken := c.Get("Authorization") + if accessToken == "" { + return nil + } + + token := strings.Split(accessToken, "Bearer ") + if len(token) != 2 { + return nil + } + + return &token[1] +} + func (m *AuthMiddlewareService) Authenticate(c *fiber.Ctx) error { - if slices.Contains(getExcludedPaths(), c.Path()) { - return c.Next() + for _, path := range getExcludedPaths() { + for p, method := range path { + if strings.HasPrefix(c.Path(), strings.TrimSuffix(p, "/*")) && c.Method() == method { + return c.Next() + } + } } - token, err := auth.ParseAccessToken(c.Cookies("access_token"), m.AuthSettings.AccessKey) + accessToken := GetAuthroizationToken(c) + if accessToken == nil { + return errors.Unauthorized.FiberError(c) + } + + fmt.Println("accessToken", *accessToken) + + token, err := func() (*jwt.Token, error) { + return jwt.ParseWithClaims(*accessToken, &auth.CustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(m.AuthSettings.AccessKey.Expose()), nil + }) + }() if err != nil { return errors.Unauthorized.FiberError(c) } + fmt.Println("token", token) + claims, ok := token.Claims.(*auth.CustomClaims) if !ok || !token.Valid { return errors.Unauthorized.FiberError(c) } - if auth.IsBlacklisted(c.Cookies("access_token")) { - return errors.Unauthorized.FiberError(c) - } + fmt.Println("claims", claims) + // if auth.IsBlacklisted(*accessToken) { + // return errors.Unauthorized.FiberError(c) + // } c.Locals("claims", claims) @@ -73,12 +107,7 @@ func (m *AuthMiddlewareService) Authorize(requiredPermissions ...auth.Permission return c.Next() } - role, err := auth.GetRoleFromToken(c.Cookies("access_token"), m.AuthSettings.AccessKey) - if err != nil { - return errors.Unauthorized.FiberError(c) - } - - userPermissions := auth.GetPermissions(models.UserRole(*role)) + userPermissions := auth.GetPermissions(models.UserRole(claims.Role)) for _, requiredPermission := range requiredPermissions { if !slices.Contains(userPermissions, requiredPermission) { @@ -90,6 +119,7 @@ func (m *AuthMiddlewareService) Authorize(requiredPermissions ...auth.Permission } } +// TODO: implement rate limiting with redis func (m *AuthMiddlewareService) Limiter(rate int, expiration time.Duration) func(c *fiber.Ctx) error { return limiter.New(limiter.Config{ Max: rate, diff --git a/backend/src/middleware/club.go b/backend/src/middleware/club.go index 96f965a11..97e62687d 100644 --- a/backend/src/middleware/club.go +++ b/backend/src/middleware/club.go @@ -21,14 +21,9 @@ func (m *AuthMiddlewareService) ClubAuthorizeById(c *fiber.Ctx) error { return errors.FailedToValidateID.FiberError(c) } - token, tokenErr := auth.ParseAccessToken(c.Cookies("access_token"), m.AuthSettings.AccessKey) - if tokenErr != nil { - return errors.FailedToParseAccessToken.FiberError(c) - } - - claims, ok := token.Claims.(*auth.CustomClaims) - if !ok || !token.Valid { - return errors.FailedToValidateAccessToken.FiberError(c) + claims, err := auth.From(c) + if err != nil { + return err.FiberError(c) } issuerUUID, issueErr := utilities.ValidateID(claims.Issuer) @@ -36,13 +31,11 @@ func (m *AuthMiddlewareService) ClubAuthorizeById(c *fiber.Ctx) error { return errors.FailedToParseAccessToken.FiberError(c) } - // use clubID to get the list of admin for a certain club clubAdmin, clubErr := transactions.GetAdminIDs(m.DB, *clubUUID) if clubErr != nil { return err } - // check issuerID against the list of admin for the certain club if slices.Contains(clubAdmin, *issuerUUID) { return c.Next() } diff --git a/backend/src/middleware/user.go b/backend/src/middleware/user.go index 6767670b8..199ff2817 100644 --- a/backend/src/middleware/user.go +++ b/backend/src/middleware/user.go @@ -7,7 +7,7 @@ import ( "github.com/gofiber/fiber/v2" ) -// Authorizes admins of the specific club to make this request, skips check if super user +// Authorizes admins of the specified club to make this request, skips check if super user func (m *AuthMiddlewareService) UserAuthorizeById(c *fiber.Ctx) error { if m.IsSuper(c) { return c.Next() @@ -18,14 +18,9 @@ func (m *AuthMiddlewareService) UserAuthorizeById(c *fiber.Ctx) error { return errors.FailedToValidateID.FiberError(c) } - token, tokenErr := auth.ParseAccessToken(c.Cookies("access_token"), m.AuthSettings.AccessKey) - if tokenErr != nil { - return err - } - - claims, ok := token.Claims.(*auth.CustomClaims) - if !ok || !token.Valid { - return errors.FailedToValidateAccessToken.FiberError(c) + claims, err := auth.From(c) + if err != nil { + return err.FiberError(c) } issuerIDAsUUID, err := utilities.ValidateID(claims.Issuer) diff --git a/backend/src/models/user.go b/backend/src/models/user.go index 7b64be555..3e57677aa 100644 --- a/backend/src/models/user.go +++ b/backend/src/models/user.go @@ -37,11 +37,16 @@ const ( Graduate Year = 6 ) +type Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + type User struct { Model Role UserRole `gorm:"type:varchar(255);default:'student'" json:"role" validate:"required,oneof=super student"` - NUID string `gorm:"column:nuid;type:varchar(9);unique" json:"nuid" validate:"required,numeric,len=9"` + NUID string `gorm:"type:varchar(9);column:nuid" json:"nuid" validate:"required,numeric,len=9"` FirstName string `gorm:"type:varchar(255)" json:"first_name" validate:"required,max=255"` LastName string `gorm:"type:varchar(255)" json:"last_name" validate:"required,max=255"` Email string `gorm:"type:varchar(255);unique" json:"email" validate:"required,email,max=255"` @@ -62,13 +67,14 @@ type User struct { } type CreateUserRequestBody struct { - NUID string `json:"nuid" validate:"required,numeric,len=9"` - FirstName string `json:"first_name" validate:"required,max=255"` - LastName string `json:"last_name" validate:"required,max=255"` - Email string `json:"email" validate:"required,email,neu_email,max=255"` - Password string `json:"password" validate:"required,password,min=8,max=255"` - College College `json:"college" validate:"required,oneof=CAMD DMSB KCCS CE BCHS SL CPS CS CSSH"` - Year Year `json:"year" validate:"required,min=1,max=6"` + FirstName string `json:"first_name" validate:"required,max=255"` + LastName string `json:"last_name" validate:"required,max=255"` + Email string `json:"email" validate:"required,email,neu_email,max=255"` + Password string `json:"password" validate:"required,password,min=8,max=255"` + // Optional fields + NUID string `json:"nuid" validate:"omitempty,numeric,len=9"` + College College `json:"college" validate:"omitempty,oneof=CAMD DMSB KCCS CE BCHS SL CPS CS CSSH"` + Year Year `json:"year" validate:"omitempty,min=1,max=6"` } type UpdateUserRequestBody struct { @@ -76,11 +82,12 @@ type UpdateUserRequestBody struct { LastName string `json:"last_name" validate:"omitempty,max=255"` College College `json:"college" validate:"omitempty,oneof=CAMD DMSB KCCS CE BCHS SL CPS CS CSSH"` Year Year `json:"year" validate:"omitempty,min=1,max=6"` + NUID string `json:"nuid" validate:"omitempty,len=9"` // TODO: remove this } type LoginUserResponseBody struct { Email string `json:"email" validate:"required,email"` - Password string `json:"password"` // validate:"min=8,max=255,password"` + Password string `json:"password" validate:"required,password,min=8,max=255"` } type UpdatePasswordRequestBody struct { @@ -88,6 +95,10 @@ type UpdatePasswordRequestBody struct { NewPassword string `json:"new_password" validate:"required,password,nefield=OldPassword,min=8,max=255"` } +type RefreshTokenRequestBody struct { + RefreshToken string `json:"refresh_token" validate:"required"` +} + type CreateUserTagsBody struct { Tags []uuid.UUID `json:"tags" validate:"required"` } diff --git a/backend/src/models/verification.go b/backend/src/models/verification.go index da694694c..434bb727e 100644 --- a/backend/src/models/verification.go +++ b/backend/src/models/verification.go @@ -20,21 +20,17 @@ type Verification struct { Type VerificationType `gorm:"type:varchar(255);not null" json:"type" validate:"required,oneof=email_verification password_reset"` } -type EmailVerificationRequestBody struct { - Email string `json:"email" validate:"required,email"` -} - type VerifyEmailRequestBody struct { Email string `json:"email" validate:"required,email"` Token string `json:"token" validate:"required,len=6"` } -type PasswordResetRequestBody struct { - Email string `json:"email" validate:"required,email"` -} - type VerifyPasswordResetTokenRequestBody struct { Token string `json:"token" validate:"required"` NewPassword string `json:"new_password" validate:"required,min=8,password"` VerifyNewPassword string `json:"verify_new_password" validate:"required,min=8,password,eqfield=NewPassword"` } + +type EmailRequestBody struct { + Email string `json:"email" validate:"required,email"` +} diff --git a/backend/src/search/pinecone.go b/backend/src/search/pinecone.go index 6f5a07257..bc9e5e1c8 100644 --- a/backend/src/search/pinecone.go +++ b/backend/src/search/pinecone.go @@ -27,18 +27,24 @@ type PineconeClient struct { Settings config.PineconeSettings IndexName *mattress.Secret[string] openAIClient *OpenAIClient + dev bool } // Connects to an existing Pinecone index, using the host and keys provided in settings. -func NewPineconeClient(openAIClient *OpenAIClient, settings config.PineconeSettings) *PineconeClient { +func NewPineconeClient(openAIClient *OpenAIClient, settings config.PineconeSettings, dev bool) *PineconeClient { return &PineconeClient{ Settings: settings, openAIClient: openAIClient, + dev: dev, } } // Seeds the pinecone index with the clubs currently in the database. func (c *PineconeClient) Seed(db *gorm.DB) *errors.Error { + if c.dev { + return nil + } + var clubs []models.Club if err := db.Find(&clubs).Error; err != nil { @@ -99,6 +105,10 @@ func (c *PineconeClient) Upsert(items []Searchable) *errors.Error { return nil } + if c.dev { + return nil + } + embeddings, embeddingErr := c.openAIClient.CreateEmbedding(items) if embeddingErr != nil { return &errors.FailedToUpsertToPinecone @@ -155,6 +165,10 @@ func (c *PineconeClient) Delete(items []Searchable) *errors.Error { return nil } + if c.dev { + return nil + } + // Ensure all items are in the same namespace namespace := items[0].Namespace() for _, item := range items { @@ -221,6 +235,10 @@ type PineconeSearchResponseBody struct { // Runs a search on the Pinecone index given a searchable item, and returns the topK most similar // elements' ids. func (c *PineconeClient) Search(item Searchable, topK int) ([]string, *errors.Error) { + if c.dev { + return nil, nil + } + values, embeddingErr := c.openAIClient.CreateEmbedding([]Searchable{item}) if embeddingErr != nil { return []string{}, embeddingErr diff --git a/backend/src/server/routes/auth.go b/backend/src/server/routes/auth.go index 6a7f5db2a..65745357c 100644 --- a/backend/src/server/routes/auth.go +++ b/backend/src/server/routes/auth.go @@ -1,26 +1,26 @@ package routes import ( - "time" - "github.com/GenerateNU/sac/backend/src/controllers" "github.com/GenerateNU/sac/backend/src/services" "github.com/GenerateNU/sac/backend/src/types" ) func Auth(params types.RouteParams) { - authController := controllers.NewAuthController(services.NewAuthService(params.ServiceParams), params.Settings) + authController := controllers.NewAuthController(services.NewAuthService(params.ServiceParams)) // api/v1/auth/* auth := params.Router.Group("/auth") + auth.Post("/logout", authController.Logout) auth.Post("/login", authController.Login) - auth.Get("/logout", authController.Logout) - auth.Get("/refresh", authController.Refresh) - auth.Get("/me", authController.Me) - auth.Post("/update-password/:userID", params.AuthMiddleware.Limiter(2, 1*time.Minute), params.AuthMiddleware.UserAuthorizeById, authController.UpdatePassword) - auth.Post("/send-code/:userID", authController.SendCode) + auth.Post("/refresh", authController.Refresh) + + // TODO: rate limit + auth.Post("/send-code", authController.SendCode) auth.Post("/verify-email", authController.VerifyEmail) + + // TODO: rate limit auth.Post("/forgot-password", authController.ForgotPassword) auth.Post("/verify-reset", authController.VerifyPasswordResetToken) } diff --git a/backend/src/server/routes/user.go b/backend/src/server/routes/user.go index e668b62a4..6c268656a 100644 --- a/backend/src/server/routes/user.go +++ b/backend/src/server/routes/user.go @@ -11,7 +11,6 @@ import ( func UserRoutes(userParams types.RouteParams) { usersRouter := User(userParams) - // update the router in params userParams.Router = usersRouter UserTag(userParams) @@ -25,8 +24,9 @@ func User(userParams types.RouteParams) fiber.Router { // api/v1/users/* users := userParams.Router.Group("/users") - users.Post("/", userController.CreateUser) users.Get("/", userParams.AuthMiddleware.Authorize(p.ReadAll), userController.GetUsers) + users.Post("/", userController.CreateUser) + users.Get("/me", userController.GetMe) // api/v1/users/:userID/* usersID := users.Group("/:userID") @@ -34,6 +34,7 @@ func User(userParams types.RouteParams) fiber.Router { usersID.Get("/", userController.GetUser) usersID.Patch("/", userController.UpdateUser) + usersID.Patch("/password", userController.UpdatePassword) usersID.Delete("/", userController.DeleteUser) return usersID diff --git a/backend/src/server/server.go b/backend/src/server/server.go index a12a7b4fb..8bc9a24c6 100644 --- a/backend/src/server/server.go +++ b/backend/src/server/server.go @@ -16,6 +16,7 @@ import ( "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v2/middleware/requestid" + "github.com/golang-jwt/jwt" "gorm.io/gorm" ) @@ -27,6 +28,7 @@ import ( // @contact.email oduneye.d@northeastern.edu and ladley.g@northeastern.edu // @host 127.0.0.1:8080 // @BasePath / +// @schemes http func Init(db *gorm.DB, pinecone search.PineconeClientInterface, settings config.Settings) *fiber.App { app := newFiberApp(settings.Application) @@ -36,22 +38,22 @@ func Init(db *gorm.DB, pinecone search.PineconeClientInterface, settings config. } authMiddleware := middleware.NewAuthAuthMiddlewareService(db, validate, settings.Auth) - emailService := email.NewEmailClient(settings.ResendSettings) - clerkService := auth.NewClerkService(settings.ClerkSettings) + resend := email.NewResendClient(settings.ResendSettings, true) + jwt := auth.NewJWTClient(settings.Auth, jwt.SigningMethodHS256) apiv1 := app.Group("/api/v1") apiv1.Use(authMiddleware.Authenticate) routeParams := types.RouteParams{ Router: apiv1, - Settings: settings.Auth, AuthMiddleware: authMiddleware, ServiceParams: types.ServiceParams{ - DB: db, - Validate: validate, - Email: emailService, - Clerk: clerkService, - Pinecone: &pinecone, + DB: db, + Validate: validate, + Resend: resend, + Pinecone: &pinecone, + JWT: jwt, + AuthSettings: settings.Auth, }, } diff --git a/backend/src/services/auth.go b/backend/src/services/auth.go index 885de8c46..18fa7c3de 100644 --- a/backend/src/services/auth.go +++ b/backend/src/services/auth.go @@ -1,6 +1,7 @@ package services import ( + "fmt" "time" "github.com/GenerateNU/sac/backend/src/auth" @@ -9,16 +10,20 @@ import ( "github.com/GenerateNU/sac/backend/src/transactions" "github.com/GenerateNU/sac/backend/src/types" "github.com/GenerateNU/sac/backend/src/utilities" + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt" ) type AuthServiceInterface interface { GetRole(id string) (*models.UserRole, *errors.Error) - Me(id string) (*models.User, *errors.Error) - Login(userBody models.LoginUserResponseBody) (*models.User, *errors.Error) - UpdatePassword(id string, passwordBody models.UpdatePasswordRequestBody) *errors.Error - SendCode(userID string) *errors.Error + SetResponseTokens(c *fiber.Ctx, tokens *auth.Token) *errors.Error + Login(userBody models.LoginUserResponseBody) (*models.User, *auth.Token, *errors.Error) + Refresh(refreshToken string) (*auth.Token, *errors.Error) + + SendCode(email string) *errors.Error VerifyEmail(emailBody models.VerifyEmailRequestBody) *errors.Error - ForgotPassword(userBody models.PasswordResetRequestBody) *errors.Error + + ForgotPassword(email string) *errors.Error VerifyPasswordResetToken(passwordBody models.VerifyPasswordResetTokenRequestBody) *errors.Error } @@ -30,170 +35,149 @@ func NewAuthService(serviceParams types.ServiceParams) *AuthService { return &AuthService{serviceParams} } -func (a *AuthService) Me(id string) (*models.User, *errors.Error) { - idAsUint, idErr := utilities.ValidateID(id) - if idErr != nil { - return nil, idErr - } - - user, err := transactions.GetUser(a.DB, *idAsUint) - if err != nil { - return nil, err - } +// TODO: organize this +func (a *AuthService) SetResponseTokens(c *fiber.Ctx, tokens *auth.Token) *errors.Error { + // c.Cookie(&fiber.Cookie{ + // Name: "refresh_token", + // Value: "", + // Expires: time.Now().Add(-time.Hour), + // HTTPOnly: true, + // }) + // c.Set("Authorization", "") + + // Set the tokens in the response + // should also blacklist the old refresh and access tokens + + c.Set("Authorization", fmt.Sprintf("Bearer %s", tokens.AccessToken)) + c.Cookie(&fiber.Cookie{ + Name: "refresh_token", + Value: string(tokens.RefreshToken), + Expires: time.Now().Add(time.Hour * time.Duration(a.AuthSettings.RefreshTokenExpiry)), + HTTPOnly: true, + }) - return user, nil + return nil } -func (a *AuthService) Login(userBody models.LoginUserResponseBody) (*models.User, *errors.Error) { - if err := a.Validate.Struct(userBody); err != nil { - return nil, &errors.FailedToValidateUser +func (a *AuthService) Login(loginBody models.LoginUserResponseBody) (*models.User, *auth.Token, *errors.Error) { + if err := a.Validate.Struct(loginBody); err != nil { + return nil, nil, &errors.FailedToValidateUser } - user, getUserByEmailErr := transactions.GetUserByEmail(a.DB, userBody.Email) + user, getUserByEmailErr := transactions.GetUserByEmail(a.DB, loginBody.Email) if getUserByEmailErr != nil { - return nil, getUserByEmailErr + return nil, nil, getUserByEmailErr } - correct, passwordErr := auth.CompareHash(userBody.Password, user.PasswordHash) + correct, passwordErr := auth.CompareHash(loginBody.Password, user.PasswordHash) if passwordErr != nil || !correct { - return nil, &errors.FailedToValidateUser + return nil, nil, &errors.FailedToValidateUser + } + + tokens, err := a.JWT.GenerateTokenPair(auth.Claims{ + StandardClaims: &jwt.StandardClaims{ + IssuedAt: time.Now().Unix(), + Issuer: user.ID.String(), + }, + CustomClaims: &jwt.MapClaims{ + "role": user.Role, + }, + }, auth.Claims{ + StandardClaims: &jwt.StandardClaims{ + IssuedAt: time.Now().Unix(), + Issuer: user.ID.String(), + }, + }) + if err != nil { + return nil, nil, &errors.FailedToGenerateToken } - return user, nil + return user, tokens, nil } -func (a *AuthService) GetRole(id string) (*models.UserRole, *errors.Error) { - idAsUint, idErr := utilities.ValidateID(id) - if idErr != nil { - return nil, idErr - } - - user, err := transactions.GetUser(a.DB, *idAsUint) +func (a *AuthService) Refresh(refreshToken string) (*auth.Token, *errors.Error) { + claims, err := a.JWT.ExtractClaims(refreshToken, auth.RefreshToken) if err != nil { - return nil, err + return nil, &errors.FailedToExtractClaims + } + + role, roleErr := a.GetRole(claims["iss"].(string)) + if roleErr != nil { + return nil, roleErr + } + + tokens, err := a.JWT.GenerateTokenPair(auth.Claims{ + StandardClaims: &jwt.StandardClaims{ + IssuedAt: time.Now().Unix(), + Issuer: claims["iss"].(string), + }, + CustomClaims: &jwt.MapClaims{ + "role": role, + }, + }, auth.Claims{ + StandardClaims: &jwt.StandardClaims{ + IssuedAt: time.Now().Unix(), + Issuer: claims["iss"].(string), + }, + }) + if err != nil { + return nil, &errors.FailedToGenerateToken } - role := user.Role - - return &role, nil + return tokens, nil } -func (a *AuthService) UpdatePassword(id string, passwordBody models.UpdatePasswordRequestBody) *errors.Error { - idAsUint, idErr := utilities.ValidateID(id) +func (a *AuthService) GetRole(id string) (*models.UserRole, *errors.Error) { + idAsUUID, idErr := utilities.ValidateID(id) if idErr != nil { - return idErr - } - - if err := a.Validate.Struct(passwordBody); err != nil { - return &errors.FailedToValidateUpdatePasswordBody + return nil, idErr } - tx := a.DB.Begin() - defer func() { - if r := recover(); r != nil { - tx.Rollback() - } - }() - - passwordHash, err := transactions.GetUserPasswordHash(tx, *idAsUint) + user, err := transactions.GetUser(a.DB, *idAsUUID) if err != nil { - tx.Rollback() - return err - } - - correct, passwordErr := auth.CompareHash(passwordBody.OldPassword, *passwordHash) - if passwordErr != nil || !correct { - tx.Rollback() - return &errors.FailedToValidateUser - } - - hash, hashErr := auth.ComputeHash(passwordBody.NewPassword) - if hashErr != nil { - tx.Rollback() - return &errors.FailedToValidateUser - } - - updateErr := transactions.UpdatePassword(tx, *idAsUint, *hash) - if updateErr != nil { - tx.Rollback() - return updateErr - } - - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return &errors.FailedToUpdatePassword + return nil, err } - return nil + return &user.Role, nil } -/* trunk-ignore(golangci-lint/cyclop) */ -func (a *AuthService) ForgotPassword(userBody models.PasswordResetRequestBody) *errors.Error { - if err := a.Validate.Struct(userBody); err != nil { - return &errors.FailedToValidateUser - } - - tx := a.DB.Begin() - defer func() { - if r := recover(); r != nil { - tx.Rollback() - } - }() - - user, err := transactions.GetUserByEmail(tx, userBody.Email) +func (a *AuthService) ForgotPassword(email string) *errors.Error { + user, err := transactions.GetUserByEmail(a.DB, email) if err != nil { - return nil // Do not return error if user does not exist + return nil } - // Check for existing or generate new password reset token - activeToken, tokenErr := transactions.GetActivePasswordResetTokenByUserID(a.DB, user.ID) + activeToken, tokenErr := transactions.GetActiveTokenByUserID(a.DB, user.ID, models.PasswordResetType) if tokenErr != nil { - if tokenErr != &errors.PasswordResetTokenNotFound { + if tokenErr != &errors.TokenNotFound { return tokenErr } } if activeToken != nil { - sendErr := a.Email.SendPasswordResetEmail(user.FirstName, user.Email, activeToken.Token) + sendErr := a.Resend.SendPasswordResetEmail(user.FirstName, user.Email, activeToken.Token) if sendErr != nil { - tx.Rollback() return &errors.FailedToSendEmail } - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return &errors.FailedToGenerateToken - } - return nil } - // Generate token if none exists token, generateErr := auth.GenerateURLSafeToken(64) if generateErr != nil { - tx.Rollback() return &errors.FailedToGenerateToken } - // Save token to database - saveErr := transactions.SavePasswordResetToken(tx, user.ID, *token) + saveErr := transactions.SaveToken(a.DB, user.ID, *token, models.PasswordResetType, time.Now().Add(time.Hour*24).UTC()) if saveErr != nil { - tx.Rollback() return saveErr } - // Send email - sendErr := a.Email.SendPasswordResetEmail(user.FirstName, user.Email, *token) + sendErr := a.Resend.SendPasswordResetEmail(user.FirstName, user.Email, *token) if sendErr != nil { - tx.Rollback() return sendErr } - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return &errors.FailedToGenerateToken - } - return nil } @@ -202,37 +186,34 @@ func (a *AuthService) VerifyPasswordResetToken(passwordBody models.VerifyPasswor return &errors.FailedToValidateUser } - tx := a.DB.Begin() - defer func() { - if r := recover(); r != nil { - tx.Rollback() - } - }() - - token, tokenErr := transactions.GetPasswordResetToken(tx, passwordBody.Token) + token, tokenErr := transactions.GetToken(a.DB, passwordBody.Token, models.PasswordResetType) if tokenErr != nil { - tx.Rollback() return tokenErr } if token.ExpiresAt.Before(time.Now().UTC()) { - tx.Rollback() return &errors.TokenExpired } hash, hashErr := auth.ComputeHash(passwordBody.NewPassword) if hashErr != nil { - tx.Rollback() return &errors.FailedToValidateUser } + tx := a.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + updateErr := transactions.UpdatePassword(tx, token.UserID, *hash) if updateErr != nil { tx.Rollback() return updateErr } - deleteErr := transactions.DeletePasswordResetToken(tx, passwordBody.Token) + deleteErr := transactions.DeleteToken(tx, passwordBody.Token, models.PasswordResetType) if deleteErr != nil { tx.Rollback() return deleteErr @@ -246,53 +227,47 @@ func (a *AuthService) VerifyPasswordResetToken(passwordBody models.VerifyPasswor return nil } -func (a *AuthService) SendCode(userID string) *errors.Error { - idAsUint, idErr := utilities.ValidateID(userID) - if idErr != nil { - return idErr - } - - tx := a.DB.Begin() - defer func() { - if r := recover(); r != nil { - tx.Rollback() - } - }() - - user, err := transactions.GetUser(tx, *idAsUint) +func (a *AuthService) SendCode(email string) *errors.Error { + user, err := transactions.GetUserByEmail(a.DB, email) if err != nil { - tx.Rollback() return err } if user.IsVerified { - tx.Rollback() return &errors.EmailAlreadyVerified } + activeOTP, tokenErr := transactions.GetActiveTokenByUserID(a.DB, user.ID, models.EmailVerificationType) + if tokenErr != nil { + if tokenErr != &errors.TokenNotFound { + return tokenErr + } + } + + if activeOTP != nil { + sendErr := a.Resend.SendEmailVerification(user.Email, activeOTP.Token) + if sendErr != nil { + return &errors.FailedToSendEmail + } + + return nil + } + otp, otpErr := auth.GenerateOTP(6) if otpErr != nil { - tx.Rollback() return &errors.FailedToGenerateOTP } - saveErr := transactions.SaveOTP(tx, user.ID, *otp) + saveErr := transactions.SaveToken(a.DB, user.ID, *otp, models.EmailVerificationType, time.Now().Add(time.Minute*5).UTC()) if saveErr != nil { - tx.Rollback() return saveErr } - sendErr := a.Email.SendEmailVerification(user.Email, *otp) + sendErr := a.Resend.SendEmailVerification(user.Email, *otp) if sendErr != nil { - tx.Rollback() return &errors.FailedToSendEmail } - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return &errors.FailedToSendCode - } - return nil } @@ -302,47 +277,42 @@ func (a *AuthService) VerifyEmail(emailBody models.VerifyEmailRequestBody) *erro return &errors.FailedToValidateUser } - tx := a.DB.Begin() - defer func() { - if r := recover(); r != nil { - tx.Rollback() - } - }() - - user, err := transactions.GetUserByEmail(tx, emailBody.Email) + user, err := transactions.GetUserByEmail(a.DB, emailBody.Email) if err != nil { - tx.Rollback() return err } if user.IsVerified { - tx.Rollback() return &errors.EmailAlreadyVerified } - otp, otpErr := transactions.GetOTP(tx, user.ID) + otp, otpErr := transactions.GetToken(a.DB, emailBody.Token, models.EmailVerificationType) if otpErr != nil { - tx.Rollback() return otpErr } if otp.Token != emailBody.Token { - tx.Rollback() return &errors.InvalidOTP } if otp.ExpiresAt.Before(time.Now().UTC()) { - tx.Rollback() return &errors.OTPExpired } + tx := a.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + updateErr := transactions.UpdateEmailVerification(tx, user.ID) if updateErr != nil { tx.Rollback() return updateErr } - deleteErr := transactions.DeleteOTP(tx, user.ID) + deleteErr := transactions.DeleteToken(tx, emailBody.Token, models.EmailVerificationType) if deleteErr != nil { tx.Rollback() return deleteErr diff --git a/backend/src/services/user.go b/backend/src/services/user.go index 731321edb..238a566ae 100644 --- a/backend/src/services/user.go +++ b/backend/src/services/user.go @@ -1,6 +1,7 @@ package services import ( + "fmt" "strings" "github.com/GenerateNU/sac/backend/src/auth" @@ -9,13 +10,16 @@ import ( "github.com/GenerateNU/sac/backend/src/transactions" "github.com/GenerateNU/sac/backend/src/types" "github.com/GenerateNU/sac/backend/src/utilities" + "github.com/gofiber/fiber/v2" ) type UserServiceInterface interface { - CreateUser(userBody models.CreateUserRequestBody) (*models.User, *errors.Error) GetUsers(limit string, page string) ([]models.User, *errors.Error) + GetMe(id string) (*models.User, *errors.Error) GetUser(id string) (*models.User, *errors.Error) + CreateUser(c *fiber.Ctx, userBody models.CreateUserRequestBody) (*models.User, *errors.Error) UpdateUser(id string, userBody models.UpdateUserRequestBody) (*models.User, *errors.Error) + UpdatePassword(id string, passwordBody models.UpdatePasswordRequestBody) *errors.Error DeleteUser(id string) *errors.Error } @@ -27,7 +31,7 @@ func NewUserService(serviceParams types.ServiceParams) *UserService { return &UserService{serviceParams} } -func (u *UserService) CreateUser(userBody models.CreateUserRequestBody) (*models.User, *errors.Error) { +func (u *UserService) CreateUser(c *fiber.Ctx, userBody models.CreateUserRequestBody) (*models.User, *errors.Error) { if err := u.Validate.Struct(userBody); err != nil { return nil, &errors.FailedToValidateUser } @@ -45,10 +49,33 @@ func (u *UserService) CreateUser(userBody models.CreateUserRequestBody) (*models user.Email = strings.ToLower(userBody.Email) user.PasswordHash = *passwordHash - // send email creation event to email service - // email.SendWelcomeEmail(user.Name, user.Email) + emailErr := u.Resend.SendWelcomeEmail(fmt.Sprintf("%s %s", user.FirstName, user.LastName), user.Email) + if emailErr != nil { + return nil, &errors.FailedToSendEmail + } + + user, userErr := transactions.CreateUser(u.DB, user) + if userErr != nil { + return nil, userErr + } + + authService := NewAuthService(u.ServiceParams) + authErr := authService.SendCode(user.Email) + if authErr != nil { + return nil, authErr + } + + _, tokens, authErr := authService.Login(models.LoginUserResponseBody{Email: user.Email, Password: userBody.Password}) + if authErr != nil { + return nil, authErr + } + + authErr = authService.SetResponseTokens(c, tokens) + if authErr != nil { + return nil, authErr + } - return transactions.CreateUser(u.DB, user) + return user, nil } func (u *UserService) GetUsers(limit string, page string) ([]models.User, *errors.Error) { @@ -65,6 +92,20 @@ func (u *UserService) GetUsers(limit string, page string) ([]models.User, *error return transactions.GetUsers(u.DB, *limitAsInt, *pageAsInt) } +func (u *UserService) GetMe(id string) (*models.User, *errors.Error) { + idAsUUID, idErr := utilities.ValidateID(id) + if idErr != nil { + return nil, idErr + } + + user, err := transactions.GetUser(u.DB, *idAsUUID) + if err != nil { + return nil, err + } + + return user, nil +} + func (u *UserService) GetUser(id string) (*models.User, *errors.Error) { idAsUUID, err := utilities.ValidateID(id) if err != nil { @@ -96,6 +137,34 @@ func (u *UserService) UpdateUser(id string, userBody models.UpdateUserRequestBod return transactions.UpdateUser(u.DB, *idAsUUID, *user) } +func (u *UserService) UpdatePassword(id string, passwordBody models.UpdatePasswordRequestBody) *errors.Error { + idAsUUID, idErr := utilities.ValidateID(id) + if idErr != nil { + return idErr + } + + if err := u.Validate.Struct(passwordBody); err != nil { + return &errors.FailedToValidateUpdatePasswordBody + } + + passwordHash, err := transactions.GetUserPasswordHash(u.DB, *idAsUUID) + if err != nil { + return err + } + + correct, passwordErr := auth.CompareHash(passwordBody.OldPassword, *passwordHash) + if passwordErr != nil || !correct { + return &errors.FailedToValidateUser + } + + hash, hashErr := auth.ComputeHash(passwordBody.NewPassword) + if hashErr != nil { + return &errors.FailedToValidateUser + } + + return transactions.UpdatePassword(u.DB, *idAsUUID, *hash) +} + func (u *UserService) DeleteUser(id string) *errors.Error { idAsUUID, err := utilities.ValidateID(id) if err != nil { diff --git a/backend/src/templates/emails/welcome.html b/backend/src/templates/emails/welcome.html index deea828e8..52673f0a6 100644 --- a/backend/src/templates/emails/welcome.html +++ b/backend/src/templates/emails/welcome.html @@ -2,7 +2,7 @@ - Welcome to [Company Name]! + Welcome to Hippo!