From 70794791379d31346b07d4dea8480ead8b59c4d8 Mon Sep 17 00:00:00 2001 From: Garrett Ladley <92384606+garrettladley@users.noreply.github.com> Date: Sun, 9 Jun 2024 21:43:57 -0400 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=93=9D=20feat:=20oauth2=20providers?= =?UTF-8?q?=20(#973)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/auth/hash.go | 140 ------ backend/auth/jwt.go | 297 ----------- backend/auth/password.go | 50 -- backend/background/job.go | 17 + backend/background/jobs/jobs.go | 15 + backend/background/jobs/welcome_sender.go | 107 ++++ backend/config/app.go | 12 + backend/config/auth.go | 34 -- backend/config/oauth.go | 24 - backend/config/oauth_google.go | 38 +- backend/config/oauth_microsoft.go | 36 ++ backend/config/oauth_outlook.go | 37 -- backend/config/redis.go | 40 +- backend/config/settings.go | 88 ++-- backend/constants/jobs.go | 10 + backend/constants/redis.go | 2 - backend/database/store/active_token.go | 39 -- backend/database/store/blacklist.go | 36 -- backend/database/store/limiter.go | 50 -- backend/database/store/redis.go | 80 --- backend/database/store/store.go | 77 --- backend/database/store/storer.go | 71 +++ backend/database/store/stores.go | 11 + backend/database/super.go | 12 +- backend/docker-compose.yml | 30 +- backend/entities/auth/base/controller.go | 244 --------- backend/entities/auth/base/handlers.go | 113 +++++ backend/entities/auth/base/models.go | 20 - backend/entities/auth/base/routes.go | 46 +- backend/entities/auth/base/service.go | 461 ------------------ backend/entities/auth/base/transactions.go | 68 +-- backend/entities/categories/base/routes.go | 8 +- backend/entities/clubs/base/controller.go | 4 +- backend/entities/clubs/base/routes.go | 6 +- backend/entities/clubs/base/transactions.go | 3 - .../entities/clubs/recruitment/controller.go | 18 +- backend/entities/models/oauth.go | 35 -- backend/entities/models/tokens.go | 6 - backend/entities/models/user.go | 30 +- backend/entities/models/verification.go | 21 - backend/entities/models/welcome_task.go | 7 + backend/entities/oauth/base/controller.go | 90 ---- backend/entities/oauth/base/models.go | 6 - backend/entities/oauth/base/routes.go | 14 - backend/entities/oauth/base/service.go | 194 -------- backend/entities/oauth/base/transactions.go | 59 --- backend/entities/socials/base/routes.go | 4 +- backend/entities/tags/base/routes.go | 8 +- backend/entities/users/base/controller.go | 62 --- backend/entities/users/base/models.go | 17 +- backend/entities/users/base/routes.go | 6 +- backend/entities/users/base/service.go | 39 -- backend/entities/users/base/transactions.go | 14 +- backend/entities/users/transactions.go | 30 +- backend/go.mod | 14 +- backend/go.sum | 69 ++- backend/integrations/email/email.go | 74 +-- backend/integrations/expose.go | 4 +- backend/integrations/oauth/README.md | 3 + backend/integrations/oauth/google.go | 47 -- backend/integrations/oauth/oauth.go | 18 - backend/integrations/oauth/outlook.go | 34 -- backend/integrations/oauth/soth/goog/goog.go | 216 ++++++++ .../integrations/oauth/soth/goog/session.go | 64 +++ backend/integrations/oauth/soth/msft/msft.go | 190 ++++++++ .../integrations/oauth/soth/msft/session.go | 63 +++ backend/integrations/oauth/soth/provider.go | 67 +++ backend/integrations/oauth/soth/session.go | 21 + .../integrations/oauth/soth/sothic/params.go | 11 + .../integrations/oauth/soth/sothic/sothic.go | 419 ++++++++++++++++ backend/integrations/oauth/soth/user.go | 42 ++ backend/locals/claims.go | 27 - backend/locals/type.go | 3 +- backend/locals/user.go | 31 ++ backend/locals/user_id.go | 27 - backend/main.go | 103 ++-- backend/middleware/auth/auth.go | 107 +--- backend/middleware/auth/club.go | 70 +-- backend/middleware/auth/event.go | 68 +-- backend/middleware/auth/middleware.go | 35 +- backend/middleware/auth/user.go | 52 +- backend/middleware/utility/limiter.go | 4 +- backend/middleware/utility/middleware.go | 12 +- backend/middleware/utility/paginator.go | 4 +- backend/migrations/000001_init.down.sql | 137 +----- backend/migrations/000001_init.up.sql | 33 +- .../permission.go} | 2 +- backend/server/server.go | 41 +- .../emails/password_change_complete.templ | 74 --- .../emails/password_change_complete_templ.go | 89 ---- backend/templates/emails/password_reset.templ | 76 --- .../templates/emails/password_reset_templ.go | 100 ---- backend/tests/api/helpers/auth.go | 104 +--- backend/tests/api/helpers/dependencies.go | 4 +- backend/tests/api/helpers/redis.go | 18 +- backend/tests/api/helpers/requests.go | 4 - backend/tests/api/mocks/aws.go | 26 + backend/tests/api/mocks/aws_mock.go | 26 - backend/tests/api/mocks/jwt_mock.go | 36 -- backend/tests/api/mocks/redis.go | 31 ++ backend/tests/api/mocks/redis_mock.go | 124 ----- backend/tests/api/mocks/resend.go | 17 + backend/tests/api/mocks/resend_mock.go | 27 - backend/types/params.go | 13 +- backend/utilities/api_error.go | 4 - backend/utilities/http.go | 8 +- config/.env.template | 34 +- go.work.sum | 103 +--- 108 files changed, 2201 insertions(+), 3815 deletions(-) delete mode 100644 backend/auth/hash.go delete mode 100644 backend/auth/jwt.go delete mode 100644 backend/auth/password.go create mode 100644 backend/background/job.go create mode 100644 backend/background/jobs/jobs.go create mode 100644 backend/background/jobs/welcome_sender.go delete mode 100644 backend/config/auth.go delete mode 100644 backend/config/oauth.go create mode 100644 backend/config/oauth_microsoft.go delete mode 100644 backend/config/oauth_outlook.go create mode 100644 backend/constants/jobs.go delete mode 100644 backend/database/store/active_token.go delete mode 100644 backend/database/store/blacklist.go delete mode 100644 backend/database/store/limiter.go delete mode 100644 backend/database/store/redis.go delete mode 100644 backend/database/store/store.go create mode 100644 backend/database/store/storer.go create mode 100644 backend/database/store/stores.go delete mode 100644 backend/entities/auth/base/controller.go create mode 100644 backend/entities/auth/base/handlers.go delete mode 100644 backend/entities/auth/base/models.go delete mode 100644 backend/entities/auth/base/service.go delete mode 100644 backend/entities/models/oauth.go delete mode 100644 backend/entities/models/tokens.go delete mode 100644 backend/entities/models/verification.go create mode 100644 backend/entities/models/welcome_task.go delete mode 100644 backend/entities/oauth/base/controller.go delete mode 100644 backend/entities/oauth/base/models.go delete mode 100644 backend/entities/oauth/base/routes.go delete mode 100644 backend/entities/oauth/base/service.go delete mode 100644 backend/entities/oauth/base/transactions.go create mode 100644 backend/integrations/oauth/README.md delete mode 100644 backend/integrations/oauth/google.go delete mode 100644 backend/integrations/oauth/oauth.go delete mode 100644 backend/integrations/oauth/outlook.go create mode 100644 backend/integrations/oauth/soth/goog/goog.go create mode 100644 backend/integrations/oauth/soth/goog/session.go create mode 100644 backend/integrations/oauth/soth/msft/msft.go create mode 100644 backend/integrations/oauth/soth/msft/session.go create mode 100644 backend/integrations/oauth/soth/provider.go create mode 100644 backend/integrations/oauth/soth/session.go create mode 100644 backend/integrations/oauth/soth/sothic/params.go create mode 100644 backend/integrations/oauth/soth/sothic/sothic.go create mode 100644 backend/integrations/oauth/soth/user.go delete mode 100644 backend/locals/claims.go create mode 100644 backend/locals/user.go delete mode 100644 backend/locals/user_id.go rename backend/{auth/permissions.go => permission/permission.go} (99%) delete mode 100644 backend/templates/emails/password_change_complete.templ delete mode 100644 backend/templates/emails/password_change_complete_templ.go delete mode 100644 backend/templates/emails/password_reset.templ delete mode 100644 backend/templates/emails/password_reset_templ.go create mode 100644 backend/tests/api/mocks/aws.go delete mode 100644 backend/tests/api/mocks/aws_mock.go delete mode 100644 backend/tests/api/mocks/jwt_mock.go create mode 100644 backend/tests/api/mocks/redis.go delete mode 100644 backend/tests/api/mocks/redis_mock.go create mode 100644 backend/tests/api/mocks/resend.go delete mode 100644 backend/tests/api/mocks/resend_mock.go diff --git a/backend/auth/hash.go b/backend/auth/hash.go deleted file mode 100644 index be04f4385..000000000 --- a/backend/auth/hash.go +++ /dev/null @@ -1,140 +0,0 @@ -package auth - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/base64" - "errors" - "fmt" - "strings" - - "golang.org/x/crypto/argon2" -) - -type params struct { - memory uint32 - iterations uint32 - parallelism uint8 - saltLength uint32 - keyLength uint32 -} - -func GenerateURLSafeToken(length int) (*string, error) { - token := make([]byte, length) - if _, err := rand.Read(token); err != nil { - return nil, err - } - - encodedToken := base64.RawURLEncoding.EncodeToString(token) - return &encodedToken, nil -} - -func GenerateOTP(length int) (*string, error) { - digits := "0123456789" - otp := make([]byte, length) - if _, err := rand.Read(otp); err != nil { - return nil, err - } - - for i := 0; i < length; i++ { - otp[i] = digits[int(otp[i])%10] - } - - outOtp := string(otp) - - return &outOtp, nil -} - -func ComputeHash(data string) (*string, error) { - p := ¶ms{ - memory: 64 * 1024, - iterations: 3, - parallelism: 2, - saltLength: 16, - keyLength: 32, - } - - salt := make([]byte, p.saltLength) - - if _, err := rand.Read(salt); err != nil { - return nil, err - } - - hash := argon2.IDKey([]byte(data), - salt, - p.iterations, - p.memory, - p.parallelism, - p.keyLength, - ) - - b64Salt := base64.RawStdEncoding.EncodeToString(salt) - - b64Hash := base64.RawStdEncoding.EncodeToString(hash) - - encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, p.memory, p.iterations, p.parallelism, b64Salt, b64Hash) - - return &encodedHash, nil -} - -var ( - ErrInvalidHash = errors.New("the encoded hash is not in the correct format") - ErrIncompatibleVersion = errors.New("incompatible version of argon2") -) - -func CompareHash(data string, encodedHash string) (bool, error) { - p, salt, hash, err := decodeHash(encodedHash) - if err != nil { - return false, err - } - - otherHash := argon2.IDKey([]byte(data), salt, p.iterations, p.memory, p.parallelism, p.keyLength) - - if subtle.ConstantTimeCompare(hash, otherHash) == 1 { - return true, nil - } - - return false, nil -} - -func decodeHash(encodedHash string) (p *params, salt []byte, hash []byte, err error) { - vals := strings.Split(encodedHash, "$") - - if len(vals) != 6 { - return nil, nil, nil, ErrInvalidHash - } - - var version int - - _, err = fmt.Sscanf(vals[2], "v=%d", &version) - if err != nil { - return nil, nil, nil, err - } - - if version != argon2.Version { - return nil, nil, nil, ErrIncompatibleVersion - } - - p = ¶ms{} - - _, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism) - if err != nil { - return nil, nil, nil, err - } - - salt, err = base64.RawStdEncoding.Strict().DecodeString(vals[4]) - if err != nil { - return nil, nil, nil, err - } - - p.saltLength = uint32(len(salt)) - - hash, err = base64.RawStdEncoding.Strict().DecodeString(vals[5]) - if err != nil { - return nil, nil, nil, err - } - - p.keyLength = uint32(len(hash)) - - return p, salt, hash, nil -} diff --git a/backend/auth/jwt.go b/backend/auth/jwt.go deleted file mode 100644 index fb66f5803..000000000 --- a/backend/auth/jwt.go +++ /dev/null @@ -1,297 +0,0 @@ -package auth - -import ( - "fmt" - "time" - - "github.com/GenerateNU/sac/backend/config" - "github.com/GenerateNU/sac/backend/constants" - - "github.com/GenerateNU/sac/backend/utilities" - m "github.com/garrettladley/mattress" - "github.com/gofiber/fiber/v2" - "github.com/golang-jwt/jwt" -) - -type CustomClaims struct { - jwt.StandardClaims - Role string `json:"role"` -} - -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, error) - GenerateToken(claims Claims, tokenType JWTType) ([]byte, error) - RefreshToken(token, refreshToken string, tokenType JWTType, newClaims jwt.MapClaims) ([]byte, error) - ExtractClaims(tokenString string, tokenType JWTType) (jwt.MapClaims, error) - ParseToken(tokenString string, tokenType JWTType) (*jwt.Token, error) - IsTokenValid(tokenString string, tokenType JWTType) (bool, 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) JWTClientInterface { - return &JWTClient{ - RefreshExp: constants.REFRESH_TOKEN_EXPIRY, - AccessExp: constants.ACCESS_TOKEN_EXPIRY, - RefreshKey: authSettings.RefreshKey, - AccessKey: authSettings.AccessKey, - SigningMethod: signingMethod, - } -} - -func (j *JWTClient) GenerateTokenPair(accessClaims, refreshClaims Claims) (*Token, 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 -} - -// GenerateToken generates a token with the claims passed in. -// It returns the token if successful, otherwise it returns an error. -func (j *JWTClient) GenerateToken(claims Claims, tokenType JWTType) ([]byte, 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, err := token.SignedString([]byte(secretKey)) - if err != nil { - return nil, fmt.Errorf("failed to sign token: %w", err) - } - - return []byte(signedToken), nil -} - -// ParseToken parses the token string and returns the token if successful, otherwise it returns an error. -// It uses the secret key for the token type to parse the token. -func (j *JWTClient) ParseToken(tokenString string, tokenType JWTType) (*jwt.Token, 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, fmt.Errorf("failed to parse token: %w", parseErr) - } - - return token, nil -} - -// ExtractClaims extracts the claims from the token. -// It returns the claims if successful, otherwise it returns an error. -func (j *JWTClient) ExtractClaims(tokenString string, tokenType JWTType) (jwt.MapClaims, error) { - token, err := j.ParseToken(tokenString, tokenType) - if err != nil { - return nil, err - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return nil, fmt.Errorf("failed to extract claims from token. got: %T", token.Claims) - } - - return claims, nil -} - -// IsTokenValid checks if the token is valid. -// It returns true if the token is valid, otherwise it returns false. -func (j *JWTClient) IsTokenValid(tokenString string, tokenType JWTType) (bool, error) { - token, err := j.ParseToken(tokenString, tokenType) - if err != nil { - return false, err - } - - return token.Valid, nil -} - -// RefreshToken generates a new access token using the refresh token. -// It checks if the refresh token is valid and extracts the claims from the access token. -// It then updates the issued at and expires at claims and gives the new claims priority over the old claims. -// It returns the new access token if successful, otherwise it returns an error. -func (j *JWTClient) RefreshToken(token, refreshToken string, tokenType JWTType, newClaims jwt.MapClaims) ([]byte, error) { - ok, err := j.IsTokenValid(refreshToken, RefreshToken) - if err != nil || !ok { - return nil, err - } - - claims, err := j.ExtractClaims(token, tokenType) - if err != nil { - return nil, err - } - - 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 -} - -// getSecretKey returns the secret key for the token type. -// If the token type is not present in the client, it returns an error. -func (j *JWTClient) getSecretKey(tokenType JWTType) (string, error) { - switch tokenType { - case AccessToken: - return j.AccessKey.Expose(), nil - case RefreshToken: - return j.RefreshKey.Expose(), nil - } - - return "", utilities.BadRequest(fmt.Errorf("invalid token type: %s", tokenType)) -} - -// getExpiry returns the expiry time for the token type. -// If the token type is not present in the client, it returns 0. -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 SetResponseTokens(c *fiber.Ctx, tokens *Token) { - c.Set("Authorization", fmt.Sprintf("Bearer %s", tokens.AccessToken)) - c.Cookie(&fiber.Cookie{ - Name: "refresh_token", - Value: string(tokens.RefreshToken), - Expires: time.Now().Add(constants.REFRESH_TOKEN_EXPIRY), - HTTPOnly: true, - }) -} - -func ExpireResponseTokens(c *fiber.Ctx) { - c.Set("Authorization", "") - c.Cookie(&fiber.Cookie{ - Name: "refresh_token", - Value: "", - Expires: time.Now().Add(-time.Hour), - HTTPOnly: true, - }) -} diff --git a/backend/auth/password.go b/backend/auth/password.go deleted file mode 100644 index 82856f1ef..000000000 --- a/backend/auth/password.go +++ /dev/null @@ -1,50 +0,0 @@ -package auth - -import ( - "errors" - "fmt" - "regexp" - "strings" - - "github.com/GenerateNU/sac/backend/constants" -) - -func ValidatePassword(password string) error { - var errs []string - - if len(password) < 8 { - errs = append(errs, "must be at least 8 characters long") - } - - if len(password) > 128 { // see https://github.com/OWASP/ASVS/issues/756 - errs = append(errs, "must be at most 128 characters long") - } - - if !hasDigit(password) { - errs = append(errs, "must contain at least one digit") - } - - if !hasSpecialChar(password) { - errs = append(errs, fmt.Sprintf("must contain at least one special character from: [%v]", string(constants.SPECIAL_CHARACTERS))) - } - - if len(errs) > 0 { - return errors.New(strings.Join(errs, ", ")) - } - - return nil -} - -func hasDigit(str string) bool { - return regexp.MustCompile(`[0-9]`).MatchString(str) -} - -func hasSpecialChar(str string) bool { - for _, c := range constants.SPECIAL_CHARACTERS { - if strings.Contains(str, string(c)) { - return true - } - } - - return false -} diff --git a/backend/background/job.go b/backend/background/job.go new file mode 100644 index 000000000..e2e48d757 --- /dev/null +++ b/backend/background/job.go @@ -0,0 +1,17 @@ +package background + +import "log/slog" + +type JobFunc func() + +func Go(fn JobFunc) { + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("panic in background job", r) + } + }() + + fn() + }() +} diff --git a/backend/background/jobs/jobs.go b/backend/background/jobs/jobs.go new file mode 100644 index 000000000..65ae2ad64 --- /dev/null +++ b/backend/background/jobs/jobs.go @@ -0,0 +1,15 @@ +package jobs + +import ( + "github.com/GenerateNU/sac/backend/integrations/email" + "gorm.io/gorm" +) + +type Jobs struct { + db *gorm.DB + emailer email.Emailer +} + +func New(db *gorm.DB) *Jobs { + return &Jobs{db: db} +} diff --git a/backend/background/jobs/welcome_sender.go b/backend/background/jobs/welcome_sender.go new file mode 100644 index 000000000..b98b73526 --- /dev/null +++ b/backend/background/jobs/welcome_sender.go @@ -0,0 +1,107 @@ +package jobs + +import ( + "context" + "log/slog" + "time" + + "github.com/GenerateNU/sac/backend/background" + "github.com/GenerateNU/sac/backend/constants" + "github.com/GenerateNU/sac/backend/entities/models" + "gorm.io/gorm" +) + +func (j *Jobs) WelcomeSender(ctx context.Context) background.JobFunc { + return func() { + t := time.NewTicker(constants.EMAIL_SENDER_INTERVAL) + + for range t.C { + slog.Info("sending welcome email") + + func() { + // start a separate transaction to increment the attempts + incrementTx := j.db.WithContext(ctx).Begin() + defer func() { + if r := recover(); r != nil { + incrementTx.Rollback() + } + }() + + task, err := j.dequeueWelcomeTask(incrementTx) + if err != nil { + incrementTx.Rollback() + return + } + + if task == nil { + slog.Info("no welcome tasks to send") + incrementTx.Rollback() + return + } + + // increment attempts + task.Attempts++ + if err := incrementTx.Save(task).Error; err != nil { + slog.Error("failed to increment attempts", "err", err) + incrementTx.Rollback() + return + } + + if err := incrementTx.Commit().Error; err != nil { + slog.Error("failed to commit increment transaction", "err", err) + return + } + + // start the main transaction + tx := j.db.WithContext(ctx).Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // attempt to send the email + err = j.sendWelcomeEmail(ctx, task) + if err != nil { + slog.Error("failed to send welcome email", "err", err) + if task.Attempts >= constants.MAX_EMAIL_ATTEMPTS { + if err := j.deleteWelcomeTask(tx, task); err != nil { + slog.Error("failed to delete welcome task", "err", err) + } + } + } else { + if err := j.deleteWelcomeTask(tx, task); err != nil { + slog.Error("failed to delete welcome task", "err", err) + } + } + + if err := tx.Commit().Error; err != nil { + slog.Error("failed to commit transaction", "err", err) + } + }() + } + } +} + +func (j *Jobs) dequeueWelcomeTask(tx *gorm.DB) (*models.WelcomeTask, error) { + var task models.WelcomeTask + if err := tx.Raw("SELECT email, name, attempts FROM welcome_tasks FOR UPDATE SKIP LOCKED LIMIT 1").Scan(&task).Error; err != nil { + tx.Rollback() + return nil, err + } + + if task.Email == "" && task.Name == "" { + tx.Rollback() + return nil, nil + } + + return &task, nil +} + +func (j *Jobs) deleteWelcomeTask(tx *gorm.DB, task *models.WelcomeTask) error { + return tx.Delete(task).Error +} + +func (j *Jobs) sendWelcomeEmail(ctx context.Context, task *models.WelcomeTask) error { + return j.emailer.SendWelcome(ctx, task.Email, task.Name) +} diff --git a/backend/config/app.go b/backend/config/app.go index 24c1e6561..7b6f45acd 100644 --- a/backend/config/app.go +++ b/backend/config/app.go @@ -1,7 +1,19 @@ package config +import "fmt" + type ApplicationSettings struct { Port uint16 `env:"PORT"` Host string `env:"HOST"` BaseUrl string `env:"BASE_URL"` } + +func (s *ApplicationSettings) ApplicationURL() string { + var host string + if s.Host == "127.0.0.1" { + host = "localhost" + } else { + host = s.Host + } + return fmt.Sprintf("http://%s:%d", host, s.Port) +} diff --git a/backend/config/auth.go b/backend/config/auth.go deleted file mode 100644 index e359095c7..000000000 --- a/backend/config/auth.go +++ /dev/null @@ -1,34 +0,0 @@ -package config - -import ( - "fmt" - - m "github.com/garrettladley/mattress" -) - -type AuthSettings struct { - AccessKey *m.Secret[string] - RefreshKey *m.Secret[string] -} - -type intermediateAuthSettings struct { - AccessKey string `env:"ACCESS_KEY"` - RefreshKey string `env:"REFRESH_KEY"` -} - -func (i *intermediateAuthSettings) into() (*AuthSettings, error) { - accessKey, err := m.NewSecret(i.AccessKey) - if err != nil { - return nil, fmt.Errorf("failed to create secret from access key: %s", err.Error()) - } - - refreshKey, err := m.NewSecret(i.RefreshKey) - if err != nil { - return nil, fmt.Errorf("failed to create secret from refresh key: %s", err.Error()) - } - - return &AuthSettings{ - AccessKey: accessKey, - RefreshKey: refreshKey, - }, nil -} diff --git a/backend/config/oauth.go b/backend/config/oauth.go deleted file mode 100644 index ce6032734..000000000 --- a/backend/config/oauth.go +++ /dev/null @@ -1,24 +0,0 @@ -package config - -import ( - m "github.com/garrettladley/mattress" -) - -type OAuthSettings struct { - BaseURL string - TokenURL string - ClientID *m.Secret[string] - ClientSecret *m.Secret[string] - Scopes string - RedirectURI string - ResponseType string - ResponseMode string - AccessType string - IncludeGrantedScopes string - Prompt string -} - -type OauthProviderSettings struct { - GoogleOAuthSettings *OAuthSettings - OutlookOAuthSettings *OAuthSettings -} diff --git a/backend/config/oauth_google.go b/backend/config/oauth_google.go index a26101ab0..1e7c38a15 100644 --- a/backend/config/oauth_google.go +++ b/backend/config/oauth_google.go @@ -1,38 +1,30 @@ package config -import ( - "fmt" +import m "github.com/garrettladley/mattress" - m "github.com/garrettladley/mattress" -) +type GoogleOAuthSettings struct { + Key *m.Secret[string] + Secret *m.Secret[string] +} type intermediateGoogleOAuthSettings struct { - ClientID string `env:"CLIENT_ID"` - ClientSecret string `env:"CLIENT_SECRET"` - RedirectURI string `env:"REDIRECT_URI"` + Key string `env:"KEY"` + Secret string `env:"SECRET"` } -func (i *intermediateGoogleOAuthSettings) into() (*OAuthSettings, error) { - secretClientID, err := m.NewSecret(i.ClientID) +func (i *intermediateGoogleOAuthSettings) into() (*GoogleOAuthSettings, error) { + secretKey, err := m.NewSecret(i.Key) if err != nil { - return nil, fmt.Errorf("failed to create secret from client ID: %s", err.Error()) + return nil, err } - secretClientSecret, err := m.NewSecret(i.ClientSecret) + secretSecret, err := m.NewSecret(i.Secret) if err != nil { - return nil, fmt.Errorf("failed to create secret from client secret: %s", err.Error()) + return nil, err } - return &OAuthSettings{ - BaseURL: "https://accounts.google.com/o/oauth2/v2", - TokenURL: "https://oauth2.googleapis.com", - ClientID: secretClientID, - ClientSecret: secretClientSecret, - Scopes: "https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.readonly", - ResponseType: "code", - RedirectURI: i.RedirectURI, - IncludeGrantedScopes: "true", - AccessType: "offline", - Prompt: "consent", + return &GoogleOAuthSettings{ + Key: secretKey, + Secret: secretSecret, }, nil } diff --git a/backend/config/oauth_microsoft.go b/backend/config/oauth_microsoft.go new file mode 100644 index 000000000..011b9dda5 --- /dev/null +++ b/backend/config/oauth_microsoft.go @@ -0,0 +1,36 @@ +package config + +import m "github.com/garrettladley/mattress" + +const ( + tenantID string = "a8eec281-aaa3-4dae-ac9b-9a398b9215e7" +) + +type MicrosoftOAuthSettings struct { + Key *m.Secret[string] + Secret *m.Secret[string] + Tenant string +} + +type intermediateMicrosoftOAuthSetting struct { + Key string `env:"KEY"` + Secret string `env:"SECRET"` +} + +func (i *intermediateMicrosoftOAuthSetting) into() (*MicrosoftOAuthSettings, error) { + secretKey, err := m.NewSecret(i.Key) + if err != nil { + return nil, err + } + + secretSecret, err := m.NewSecret(i.Secret) + if err != nil { + return nil, err + } + + return &MicrosoftOAuthSettings{ + Key: secretKey, + Secret: secretSecret, + Tenant: tenantID, + }, nil +} diff --git a/backend/config/oauth_outlook.go b/backend/config/oauth_outlook.go deleted file mode 100644 index ce0cb11cc..000000000 --- a/backend/config/oauth_outlook.go +++ /dev/null @@ -1,37 +0,0 @@ -package config - -import ( - "fmt" - - m "github.com/garrettladley/mattress" -) - -type intermdeiateOutlookOAuthSettings struct { - ClientID string `env:"CLIENT_ID"` - ClientSecret string `env:"CLIENT_SECRET"` - RedirectURI string `env:"REDIRECT_URI"` -} - -func (i *intermdeiateOutlookOAuthSettings) into() (*OAuthSettings, error) { - secretClientID, err := m.NewSecret(i.ClientID) - if err != nil { - return nil, fmt.Errorf("failed to create secret from client ID: %s", err.Error()) - } - - secretClientSecret, err := m.NewSecret(i.ClientSecret) - if err != nil { - return nil, fmt.Errorf("failed to create secret from client secret: %s", err.Error()) - } - - return &OAuthSettings{ - BaseURL: "https://login.microsoftonline.com/common/oauth2/v2.0", - TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0", - ClientID: secretClientID, - ClientSecret: secretClientSecret, - Scopes: "offline_access user.read calendars.readwrite", - ResponseType: "code", - RedirectURI: i.RedirectURI, - ResponseMode: "query", - Prompt: "consent", - }, nil -} diff --git a/backend/config/redis.go b/backend/config/redis.go index 27a122312..e52abd69b 100644 --- a/backend/config/redis.go +++ b/backend/config/redis.go @@ -7,14 +7,34 @@ import ( ) type RedisSettings struct { - Username string - Password *m.Secret[string] - Host string - Port uint - DB int + username string + password *m.Secret[string] + host string + port uint + db int // TLSConfig *TLSConfig } +func (r RedisSettings) Username() string { + return r.username +} + +func (r RedisSettings) Password() *m.Secret[string] { + return r.password +} + +func (r RedisSettings) Host() string { + return r.host +} + +func (r RedisSettings) Port() uint { + return r.port +} + +func (r RedisSettings) DB() int { + return r.db +} + type intermediateRedisSettings struct { Username string `env:"USERNAME"` Password string `env:"PASSWORD"` @@ -31,11 +51,11 @@ func (i *intermediateRedisSettings) into() (*RedisSettings, error) { } return &RedisSettings{ - Username: i.Username, - Password: password, - Host: i.Host, - Port: i.Port, - DB: i.DB, + username: i.Username, + password: password, + host: i.Host, + port: i.Port, + db: i.DB, // TLSConfig: i.TLSConfig.into(), }, nil } diff --git a/backend/config/settings.go b/backend/config/settings.go index 40e05d218..123a71592 100644 --- a/backend/config/settings.go +++ b/backend/config/settings.go @@ -1,39 +1,33 @@ package config type Settings struct { - Application ApplicationSettings - Database DatabaseSettings - RedisActiveTokens RedisSettings - RedisBlacklist RedisSettings - RedisLimiter RedisSettings - SuperUser SuperUserSettings - Auth AuthSettings - Calendar CalendarSettings + Application ApplicationSettings + Database DatabaseSettings + RedisLimiter RedisSettings + SuperUser SuperUserSettings + Calendar CalendarSettings Integrations } type Integrations struct { - GoogleOauth OAuthSettings - OutlookOauth OAuthSettings - AWS AWSSettings - Resend ResendSettings - Search SearchSettings + Google GoogleOAuthSettings + Microsft MicrosoftOAuthSettings + AWS AWSSettings + Resend ResendSettings + Search SearchSettings } type intermediateSettings struct { - Application ApplicationSettings `envPrefix:"SAC_APPLICATION_"` - Database intermediateDatabaseSettings `envPrefix:"SAC_DB_"` - RedisActiveTokens intermediateRedisSettings `envPrefix:"SAC_REDIS_ACTIVE_TOKENS_"` - RedisBlacklist intermediateRedisSettings `envPrefix:"SAC_REDIS_BLACKLIST_"` - RedisLimiter intermediateRedisSettings `envPrefix:"SAC_REDIS_LIMITER_"` - SuperUser intermediateSuperUserSettings `envPrefix:"SAC_SUDO_"` - Auth intermediateAuthSettings `envPrefix:"SAC_AUTH_"` - AWS intermediateAWSSettings `envPrefix:"SAC_AWS_"` - Resend intermediateResendSettings `envPrefix:"SAC_RESEND_"` - Calendar intermediateCalendarSettings `envPrefix:"SAC_CALENDAR_"` - GoogleSettings intermediateGoogleOAuthSettings `envPrefix:"SAC_GOOGLE_OAUTH_"` - OutlookSettings intermdeiateOutlookOAuthSettings `envPrefix:"SAC_OUTLOOK_OAUTH_"` - Search SearchSettings `envPrefix:"SAC_SEARCH_"` + Application ApplicationSettings `envPrefix:"SAC_APPLICATION_"` + Database intermediateDatabaseSettings `envPrefix:"SAC_DB_"` + RedisLimiter intermediateRedisSettings `envPrefix:"SAC_REDIS_LIMITER_"` + SuperUser intermediateSuperUserSettings `envPrefix:"SAC_SUDO_"` + AWS intermediateAWSSettings `envPrefix:"SAC_AWS_"` + Resend intermediateResendSettings `envPrefix:"SAC_RESEND_"` + Calendar intermediateCalendarSettings `envPrefix:"SAC_CALENDAR_"` + Google intermediateGoogleOAuthSettings `envPrefix:"SAC_GOOGLE_OAUTH_"` + Microsft intermediateMicrosoftOAuthSetting `envPrefix:"SAC_MICROSOFT_OAUTH_"` + Search SearchSettings `envPrefix:"SAC_SEARCH_"` } func (i *intermediateSettings) into() (*Settings, error) { @@ -42,16 +36,6 @@ func (i *intermediateSettings) into() (*Settings, error) { return nil, err } - redisActiveTokens, err := i.RedisActiveTokens.into() - if err != nil { - return nil, err - } - - redisBlacklist, err := i.RedisBlacklist.into() - if err != nil { - return nil, err - } - redisLimiter, err := i.RedisLimiter.into() if err != nil { return nil, err @@ -62,11 +46,6 @@ func (i *intermediateSettings) into() (*Settings, error) { return nil, err } - auth, err := i.Auth.into() - if err != nil { - return nil, err - } - aws, err := i.AWS.into() if err != nil { return nil, err @@ -82,31 +61,28 @@ func (i *intermediateSettings) into() (*Settings, error) { return nil, err } - google, err := i.GoogleSettings.into() + google, err := i.Google.into() if err != nil { return nil, err } - outlook, err := i.OutlookSettings.into() + microsoft, err := i.Microsft.into() if err != nil { return nil, err } return &Settings{ - Application: i.Application, - Database: *database, - RedisActiveTokens: *redisActiveTokens, - RedisBlacklist: *redisBlacklist, - RedisLimiter: *redisLimiter, - SuperUser: *superUser, - Auth: *auth, - Calendar: *calendar, + Application: i.Application, + Database: *database, + RedisLimiter: *redisLimiter, + SuperUser: *superUser, + Calendar: *calendar, Integrations: Integrations{ - GoogleOauth: *google, - OutlookOauth: *outlook, - AWS: *aws, - Resend: *resend, - Search: i.Search, + Google: *google, + Microsft: *microsoft, + AWS: *aws, + Resend: *resend, + Search: i.Search, }, }, nil } diff --git a/backend/constants/jobs.go b/backend/constants/jobs.go new file mode 100644 index 000000000..49f8757e3 --- /dev/null +++ b/backend/constants/jobs.go @@ -0,0 +1,10 @@ +package constants + +import "time" + +const ( + DELETE_EXPIRED_VERIFICATION_INTERVAL time.Duration = 30 * time.Minute + DELETE_EXPIRED_VERIFICATION_LIMIT int = 100 + EMAIL_SENDER_INTERVAL time.Duration = 5 * time.Second + MAX_EMAIL_ATTEMPTS int = 3 +) diff --git a/backend/constants/redis.go b/backend/constants/redis.go index 229acff5b..6f6de9c42 100644 --- a/backend/constants/redis.go +++ b/backend/constants/redis.go @@ -5,8 +5,6 @@ import "time" const ( REDIS_MAX_IDLE_CONNECTIONS int = 10 REDIS_MAX_OPEN_CONNECTIONS int = 100 - TOKEN_BLACKLIST_SKEY string = "token_blacklist" - TOKEN_BLACKLIST_KEY string = "blacklisted" RATE_LIMIT_DURATION time.Duration = 5 * time.Minute RATE_LIMIT_MAX_REQUESTS int = 5 REDIS_TIMEOUT time.Duration = 200 * time.Millisecond diff --git a/backend/database/store/active_token.go b/backend/database/store/active_token.go deleted file mode 100644 index 0fd669d29..000000000 --- a/backend/database/store/active_token.go +++ /dev/null @@ -1,39 +0,0 @@ -package store - -import ( - "context" - "time" -) - -type ActiveTokenInterface interface { - StoreRefreshToken(ctx context.Context, token string, userID string, expiry time.Duration) error - IsActive(ctx context.Context, token string) (bool, error) - GetRefreshToken(ctx context.Context, token string) (string, error) - DeleteRefreshToken(ctx context.Context, token string) error -} - -type ActiveToken struct { - StoreClient StoreClientInterface -} - -func NewActiveToken(storeClient StoreClientInterface) *ActiveToken { - return &ActiveToken{ - StoreClient: storeClient, - } -} - -func (a *ActiveToken) StoreRefreshToken(ctx context.Context, token string, userID string, expiry time.Duration) error { - return a.StoreClient.Set(ctx, token, userID, expiry) -} - -func (a *ActiveToken) GetRefreshToken(ctx context.Context, token string) (string, error) { - return a.StoreClient.Get(ctx, token) -} - -func (a *ActiveToken) IsActive(ctx context.Context, token string) (bool, error) { - return a.StoreClient.Exists(ctx, token) -} - -func (a *ActiveToken) DeleteRefreshToken(ctx context.Context, token string) error { - return a.StoreClient.Del(ctx, token) -} diff --git a/backend/database/store/blacklist.go b/backend/database/store/blacklist.go deleted file mode 100644 index 22ff6ac67..000000000 --- a/backend/database/store/blacklist.go +++ /dev/null @@ -1,36 +0,0 @@ -package store - -import ( - "context" - "time" - - "github.com/GenerateNU/sac/backend/constants" -) - -type BlacklistInterface interface { - BlacklistToken(ctx context.Context, token string, expiry time.Duration) error - IsTokenBlacklisted(ctx context.Context, token string) (bool, error) -} - -type Blacklist struct { - storeClient StoreClientInterface -} - -func NewBlacklist(storeClient StoreClientInterface) *Blacklist { - return &Blacklist{ - storeClient: storeClient, - } -} - -func (b *Blacklist) BlacklistToken(ctx context.Context, token string, expiry time.Duration) error { - err := b.storeClient.SetAdd(ctx, constants.TOKEN_BLACKLIST_SKEY, token) - if err != nil { - return err - } - - return b.storeClient.Set(ctx, token, constants.TOKEN_BLACKLIST_KEY, expiry) -} - -func (b *Blacklist) IsTokenBlacklisted(ctx context.Context, token string) (bool, error) { - return b.storeClient.SetIsMember(ctx, constants.TOKEN_BLACKLIST_SKEY, token) -} diff --git a/backend/database/store/limiter.go b/backend/database/store/limiter.go deleted file mode 100644 index c7bd0e3f0..000000000 --- a/backend/database/store/limiter.go +++ /dev/null @@ -1,50 +0,0 @@ -package store - -import ( - "context" - "time" -) - -// LimiterInterface is an implementation of https://github.com/gofiber/storage -type LimiterInterface interface { - Get(key string) ([]byte, error) - Set(key string, val []byte, exp time.Duration) error - Delete(key string) error - Reset() error - Close() error -} - -type Limiter struct { - StoreClient StoreClientInterface -} - -func NewLimiter(storeClient StoreClientInterface) *Limiter { - return &Limiter{ - StoreClient: storeClient, - } -} - -func (l *Limiter) Get(key string) ([]byte, error) { - value, err := l.StoreClient.Get(context.Background(), key) - if err != nil { - return nil, err - } - - return []byte(value), nil -} - -func (l *Limiter) Set(key string, val []byte, exp time.Duration) error { - return l.StoreClient.Set(context.Background(), key, string(val), exp) -} - -func (l *Limiter) Delete(key string) error { - return l.StoreClient.Del(context.Background(), key) -} - -func (l *Limiter) Reset() error { - return l.StoreClient.FlushAll(context.Background()) -} - -func (l *Limiter) Close() error { - return l.StoreClient.Close(context.Background()) -} diff --git a/backend/database/store/redis.go b/backend/database/store/redis.go deleted file mode 100644 index e3bf3ec7a..000000000 --- a/backend/database/store/redis.go +++ /dev/null @@ -1,80 +0,0 @@ -package store - -import ( - "bytes" - "fmt" - "log/slog" - "os/exec" - - "github.com/GenerateNU/sac/backend/config" -) - -type Stores struct { - Limiter LimiterInterface - Blacklist BlacklistInterface - ActiveToken ActiveTokenInterface -} - -func NewStores(limiter LimiterInterface, blacklist BlacklistInterface, activeToken ActiveTokenInterface) *Stores { - return &Stores{ - Limiter: limiter, - Blacklist: blacklist, - ActiveToken: activeToken, - } -} - -func ConfigureRedis(settings config.Settings) *Stores { - stores := NewStores( - NewLimiter(NewRedisClient(settings.RedisLimiter.Username, settings.RedisLimiter.Password, settings.RedisLimiter.Host, settings.RedisLimiter.Port, settings.RedisLimiter.DB)), - NewBlacklist(NewRedisClient(settings.RedisBlacklist.Username, settings.RedisBlacklist.Password, settings.RedisBlacklist.Host, settings.RedisBlacklist.Port, settings.RedisBlacklist.DB)), - NewActiveToken(NewRedisClient(settings.RedisActiveTokens.Username, settings.RedisActiveTokens.Password, settings.RedisActiveTokens.Host, settings.RedisActiveTokens.Port, settings.RedisActiveTokens.DB)), - ) - - MustEstablishConn() - - return stores -} - -// TODO: this will be a part of a larger function that will check all services -func MustEstablishConn() { - isRunning, err := isDockerComposeRunning() - if err != nil { - panic(err) - } - - if !*isRunning { - if err := restartServices(); err != nil { - panic(err) - } - } -} - -func isDockerComposeRunning() (*bool, error) { - cmd := exec.Command("docker-compose", "ps", "-q") - - var out bytes.Buffer - cmd.Stdout = &out - err := cmd.Run() - if err != nil { - return nil, fmt.Errorf("error checking if docker-compose is running: %s", err.Error()) - } - - result := out.Len() > 0 - return &result, nil -} - -func restartServices() error { - cmd := exec.Command("docker-compose", "up", "-d") - - var out bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &out - - err := cmd.Run() - if err != nil { - return fmt.Errorf("error restarting services: %s\nconsole output: %s", err.Error(), out.String()) - } - - slog.Info("Services restarted successfully.") - return nil -} diff --git a/backend/database/store/store.go b/backend/database/store/store.go deleted file mode 100644 index 82d224aec..000000000 --- a/backend/database/store/store.go +++ /dev/null @@ -1,77 +0,0 @@ -package store - -import ( - "context" - "fmt" - "runtime" - "time" - - "github.com/GenerateNU/sac/backend/constants" - m "github.com/garrettladley/mattress" - "github.com/redis/go-redis/v9" -) - -type StoreClientInterface interface { - Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error - Get(ctx context.Context, key string) (string, error) - Del(ctx context.Context, key string) error - Exists(ctx context.Context, key string) (bool, error) - SetAdd(ctx context.Context, key string, members ...interface{}) error - SetIsMember(ctx context.Context, key string, member interface{}) (bool, error) - FlushAll(ctx context.Context) error - Close(ctx context.Context) error -} - -type RedisClient struct { - client *redis.Client -} - -func NewRedisClient(username string, password *m.Secret[string], host string, port uint, db int) *RedisClient { - client := redis.NewClient(&redis.Options{ - Username: username, - Password: password.Expose(), - Addr: fmt.Sprintf("%s:%d", host, port), - DB: db, - PoolSize: 10 * runtime.GOMAXPROCS(0), - MaxActiveConns: constants.REDIS_MAX_OPEN_CONNECTIONS, - MaxIdleConns: constants.REDIS_MAX_IDLE_CONNECTIONS, - ContextTimeoutEnabled: true, - }) - - return &RedisClient{ - client: client, - } -} - -func (r *RedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { - return r.client.Set(ctx, key, value, expiration).Err() -} - -func (r *RedisClient) Get(ctx context.Context, key string) (string, error) { - return r.client.Get(ctx, key).Result() -} - -func (r *RedisClient) Del(ctx context.Context, key string) error { - return r.client.Del(ctx, key).Err() -} - -func (r *RedisClient) Exists(ctx context.Context, key string) (bool, error) { - result, err := r.client.Exists(ctx, key).Result() - return result > 0, err -} - -func (r *RedisClient) SetAdd(ctx context.Context, key string, members ...interface{}) error { - return r.client.SAdd(ctx, key, members...).Err() -} - -func (r *RedisClient) SetIsMember(ctx context.Context, key string, member interface{}) (bool, error) { - return r.client.SIsMember(ctx, key, member).Result() -} - -func (r *RedisClient) FlushAll(ctx context.Context) error { - return r.client.FlushAll(ctx).Err() -} - -func (r *RedisClient) Close(ctx context.Context) error { - return r.client.Close() -} diff --git a/backend/database/store/storer.go b/backend/database/store/storer.go new file mode 100644 index 000000000..838e121df --- /dev/null +++ b/backend/database/store/storer.go @@ -0,0 +1,71 @@ +package store + +import ( + "context" + "fmt" + "runtime" + "time" + + "github.com/GenerateNU/sac/backend/constants" + "github.com/redis/go-redis/v9" + + m "github.com/garrettladley/mattress" +) + +// an implementation of https://github.com/gofiber/storage +type Storer interface { + Get(key string) ([]byte, error) + Set(key string, val []byte, exp time.Duration) error + Delete(key string) error + Reset() error + Close() error +} + +type RedisClient struct { + client *redis.Client +} + +type RedisSettings interface { + Username() string + Password() *m.Secret[string] + Host() string + Port() uint + DB() int +} + +func NewRedisClient(settings RedisSettings) *RedisClient { + client := redis.NewClient(&redis.Options{ + Username: settings.Username(), + Password: settings.Password().Expose(), + Addr: fmt.Sprintf("%s:%d", settings.Host(), settings.Port()), + DB: settings.DB(), + PoolSize: 10 * runtime.GOMAXPROCS(0), + MaxActiveConns: constants.REDIS_MAX_OPEN_CONNECTIONS, + MaxIdleConns: constants.REDIS_MAX_IDLE_CONNECTIONS, + ContextTimeoutEnabled: true, + }) + + return &RedisClient{ + client: client, + } +} + +func (r *RedisClient) Get(key string) ([]byte, error) { + return r.client.Get(context.Background(), key).Bytes() +} + +func (r *RedisClient) Set(key string, val []byte, exp time.Duration) error { + return r.client.Set(context.Background(), key, val, exp).Err() +} + +func (r *RedisClient) Delete(key string) error { + return r.client.Del(context.Background(), key).Err() +} + +func (r *RedisClient) Reset() error { + return r.client.FlushAll(context.Background()).Err() +} + +func (r *RedisClient) Close() error { + return r.client.Close() +} diff --git a/backend/database/store/stores.go b/backend/database/store/stores.go new file mode 100644 index 000000000..b16a68f6c --- /dev/null +++ b/backend/database/store/stores.go @@ -0,0 +1,11 @@ +package store + +type Stores struct { + Limiter Storer +} + +func ConfigureStores(limiter RedisSettings) *Stores { + return &Stores{ + Limiter: NewRedisClient(limiter), + } +} diff --git a/backend/database/super.go b/backend/database/super.go index 6255b640c..3a0bf146f 100644 --- a/backend/database/super.go +++ b/backend/database/super.go @@ -1,9 +1,6 @@ package database import ( - "fmt" - - "github.com/GenerateNU/sac/backend/auth" "github.com/GenerateNU/sac/backend/config" "github.com/GenerateNU/sac/backend/entities/models" "github.com/google/uuid" @@ -12,17 +9,10 @@ import ( var SuperUserUUID uuid.UUID func SuperUser(superUserSettings config.SuperUserSettings) (*models.User, error) { - passwordHash, err := auth.ComputeHash(superUserSettings.Password.Expose()) - if err != nil { - return nil, fmt.Errorf("failed to hash super user password: %w", err) - } - return &models.User{ Role: models.Super, Email: "generatesac@gmail.com", - PasswordHash: *passwordHash, - FirstName: "SAC", - LastName: "Super", + Name: "SAC Super", Major0: models.ComputerScience, College: models.KCCS, GraduationCycle: models.May, diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 4870e808b..20ee008dd 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -1,32 +1,17 @@ services: - redis-active-tokens: + redis-session: build: context: . dockerfile: Dockerfile.redis - container_name: redis_active_tokens - ports: - - 6379:6379 - environment: - - REDIS_USERNAME=redis_active_tokens - - REDIS_PASSWORD=redis_active_tokens!#1 - - REDIS_DISABLE_DEFAULT_USER="true" - volumes: - - redis-active-data:/data - - redis-blacklist: - build: - context: . - dockerfile: Dockerfile.redis - container_name: redis_blacklist + container_name: redis_session ports: - 6380:6379 environment: - - REDIS_USERNAME=redis_blacklist - - REDIS_PASSWORD=redis_blacklist!#2 + - REDIS_USERNAME=redis_session + - REDIS_PASSWORD=redis_session!#1 - REDIS_DISABLE_DEFAULT_USER="true" volumes: - - redis-blacklist-data:/data - + - redis-session-data:/data redis-limiter: build: context: . @@ -36,7 +21,7 @@ services: - 6381:6379 environment: - REDIS_USERNAME=redis_limiter - - REDIS_PASSWORD=redis_limiter!#3 + - REDIS_PASSWORD=redis_limiter!#1 - REDIS_DISABLE_DEFAULT_USER="true" volumes: - redis-limiter-data:/data @@ -79,8 +64,7 @@ services: - opensearch-net volumes: - redis-active-data: - redis-blacklist-data: + redis-session-data: redis-limiter-data: opensearch-data1: diff --git a/backend/entities/auth/base/controller.go b/backend/entities/auth/base/controller.go deleted file mode 100644 index 52a671e97..000000000 --- a/backend/entities/auth/base/controller.go +++ /dev/null @@ -1,244 +0,0 @@ -package base - -import ( - "net/http" - - "github.com/GenerateNU/sac/backend/auth" - authEntities "github.com/GenerateNU/sac/backend/entities/auth" - users "github.com/GenerateNU/sac/backend/entities/users/base" - "github.com/GenerateNU/sac/backend/utilities" - "github.com/gofiber/fiber/v2" -) - -type AuthController struct { - authService AuthServiceInterface -} - -func NewAuthController(authService AuthServiceInterface) *AuthController { - return &AuthController{authService: authService} -} - -// Login godoc -// -// @Summary Logs in a user -// @Description Logs in a user -// @ID login-user -// @Tags auth -// @Accept json -// @Produce json -// @Param loginBody body authEntities.LoginResponseBody true "Login Body" -// @Success 200 {object} models.User -// @Failure 400 {object} error -// @Failure 404 {object} error -// @Failure 500 {object} error -// @Router /auth/login [post] -func (a *AuthController) Login(c *fiber.Ctx) error { - var userBody authEntities.LoginResponseBody - - if err := c.BodyParser(&userBody); err != nil { - return utilities.InvalidJSON() - } - - user, tokens, err := a.authService.Login(c.UserContext(), userBody) - if err != nil { - return err - } - - auth.SetResponseTokens(c, tokens) - - return c.Status(http.StatusOK).JSON(user) -} - -// Register godoc -// -// @Summary Registers a user -// @Description Registers a user -// @ID register-user -// @Tags auth -// @Accept json -// @Produce json -// @Param userBody body CreateUserRequestBody true "User Body" -// @Success 201 {object} models.User -// @Failure 400 {object} error -// @Failure 404 {object} error -// @Failure 500 {object} error -// @Router /auth/register [post] -func (a *AuthController) Register(c *fiber.Ctx) error { - var userBody users.CreateUserRequestBody - - if err := c.BodyParser(&userBody); err != nil { - return utilities.InvalidJSON() - } - - user, tokens, err := a.authService.Register(c.UserContext(), userBody) - if err != nil { - return err - } - - auth.SetResponseTokens(c, tokens) - - return c.Status(http.StatusCreated).JSON(user) -} - -// Refresh godoc -// -// @Summary Refreshes a user's access token and returns a new pair of tokens -// @Description Refreshes a user's access token and returns a new pair of tokens -// @ID refresh-user -// @Tags auth -// @Accept json -// @Produce json -// @Success 200 {object} utilities.SuccessResponse -// @Failure 400 {object} error -// @Failure 404 {object} error -// @Failure 500 {object} error -// @Router /auth/refresh [post] -func (a *AuthController) Refresh(c *fiber.Ctx) error { - var refreshBody RefreshTokenCookieBody - - if err := c.CookieParser(&refreshBody); err != nil { - return utilities.InvalidCookies() - } - - tokens, err := a.authService.Refresh(c.UserContext(), refreshBody.RefreshToken) - if err != nil { - return err - } - - auth.SetResponseTokens(c, tokens) - - return c.SendStatus(http.StatusOK) -} - -// Logout godoc -// -// @Summary Logs out a user -// @Description Logs out a user -// @ID logout-user -// @Tags auth -// @Accept json -// @Produce json -// @Success 200 {object} utilities.SuccessResponse -// @Router /auth/logout [post] -func (a *AuthController) Logout(c *fiber.Ctx) error { - if err := a.authService.Logout(c.UserContext(), c.Get("Authorization"), c.Cookies("refresh_token")); err != nil { - return err - } - - auth.ExpireResponseTokens(c) - - return c.SendStatus(http.StatusOK) -} - -// ForgotPassword godoc -// -// @Summary Generates a password reset token -// @Description Generates a password reset token -// @ID forgot-password -// @Tags auth -// @Accept json -// @Produce json -// @Param email body string true "Email" -// @Success 200 {object} utilities.SuccessResponse -// @Failure 400 {object} error -// @Failure 429 {object} error -// @Failure 500 {object} error -// @Router /auth/forgot-password [post] -func (a *AuthController) ForgotPassword(c *fiber.Ctx) error { - var emailBody EmailRequestBody - - if err := c.BodyParser(&emailBody); err != nil { - return utilities.InvalidJSON() - } - - if err := a.authService.ForgotPassword(emailBody.Email); err != nil { - return err - } - - return c.SendStatus(http.StatusOK) -} - -// VerifyPasswordResetToken godoc -// -// @Summary Verifies a password reset token -// @Description Verifies a password reset token -// @ID verify-password-reset-token -// @Tags auth -// @Accept json -// @Produce json -// @Param tokenBody body VerifyPasswordResetTokenRequestBody true "Password Reset Token Body" -// @Security Bearer -// @Success 200 {object} utilities.SuccessResponse -// @Failure 400 {object} error -// @Failure 429 {object} error -// @Failure 500 {object} error -// @Router /auth/verify-reset [post] -func (a *AuthController) VerifyPasswordResetToken(c *fiber.Ctx) error { - var tokenBody VerifyPasswordResetTokenRequestBody - - if err := c.BodyParser(&tokenBody); err != nil { - return utilities.InvalidJSON() - } - - if err := a.authService.VerifyPasswordResetToken(tokenBody); err != nil { - return err - } - - return c.SendStatus(http.StatusOK) -} - -// SendCode godoc -// -// @Summary Sends a verification code -// @Description Sends a verification code -// @ID send-verification-code -// @Tags auth -// @Accept json -// @Produce json -// @Param email body string true "Email" -// @Success 200 {object} utilities.SuccessResponse -// @Failure 400 {object} error -// @Failure 429 {object} error -// @Failure 500 {object} error -// @Router /auth/send-code [post] -func (a *AuthController) SendCode(c *fiber.Ctx) error { - var emailBody EmailRequestBody - - if err := c.BodyParser(&emailBody); err != nil { - return utilities.InvalidJSON() - } - - if err := a.authService.SendCode(emailBody.Email); err != nil { - return err - } - - return c.SendStatus(http.StatusOK) -} - -// VerifyEmail godoc -// -// @Summary Verifies an email -// @Description Verifies an email -// @ID verify-email -// @Tags auth -// @Accept json -// @Produce json -// @Param tokenBody body VerifyEmailRequestBody true "Email Verification Token Body" -// @Success 200 {object} utilities.SuccessResponse -// @Failure 400 {object} error -// @Failure 429 {object} error -// @Failure 500 {object} error -// @Router /auth/verify-email [post] -func (a *AuthController) VerifyEmail(c *fiber.Ctx) error { - var tokenBody VerifyEmailRequestBody - - if err := c.BodyParser(&tokenBody); err != nil { - return utilities.InvalidJSON() - } - - if err := a.authService.VerifyEmail(tokenBody); err != nil { - return err - } - - return c.SendStatus(http.StatusOK) -} diff --git a/backend/entities/auth/base/handlers.go b/backend/entities/auth/base/handlers.go new file mode 100644 index 000000000..6ecd5c816 --- /dev/null +++ b/backend/entities/auth/base/handlers.go @@ -0,0 +1,113 @@ +package base + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth/sothic" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type Service interface { + Login(c *fiber.Ctx) error + Provider(c *fiber.Ctx) error + ProviderCallback(c *fiber.Ctx) error + ProviderLogout(c *fiber.Ctx) error +} + +type Handler struct { + db *gorm.DB + authProvider soth.Provider +} + +func (h *Handler) Login(c *fiber.Ctx) error { + sothic.SetProvider(c, h.authProvider.Name()) + if gfUser, err := sothic.CompleteUserAuth(c); err == nil { + user, err := FindOrCreateUser(context.TODO(), h.db, gfUser) + if err != nil { + return err + } + + strUser, err := user.Marshal() + if err != nil { + return err + } + + if err := sothic.StoreInSession("user", *strUser, c); err != nil { + return err + } + + return nil + } else { + return sothic.BeginAuthHandler(c) + } +} + +func (h *Handler) Provider(c *fiber.Ctx) error { + if gfUser, err := sothic.CompleteUserAuth(c); err == nil { + user, err := FindOrCreateUser(context.TODO(), h.db, gfUser) + if err != nil { + return err + } + + strUser, err := user.Marshal() + if err != nil { + return err + } + + if err := sothic.StoreInSession("user", *strUser, c); err != nil { + return err + } + + return nil + } else { + return sothic.BeginAuthHandler(c) + } +} + +func (h *Handler) ProviderCallback(c *fiber.Ctx) error { + defer func() { + c.Cookie(&fiber.Cookie{ + Name: "redirect", + Value: "", + Expires: time.Now().Add(-1 * time.Hour), // expire the cookie immediately + // MARK: secure should be true in prod + // use go build tags to do this + HTTPOnly: true, + }) + }() + + gfUser, err := sothic.CompleteUserAuth(c) + if err != nil { + return err + } + + user, err := FindOrCreateUser(context.TODO(), h.db, gfUser) + if err != nil { + return err + } + + strUser, err := user.Marshal() + if err != nil { + return err + } + + if err := sothic.StoreInSession("user", *strUser, c); err != nil { + return err + } + + return c.Redirect(c.Cookies("redirect", "/")) +} + +func (h *Handler) ProviderLogout(c *fiber.Ctx) error { + if err := sothic.Logout(c); err != nil { + slog.Error("auth", "logout", err) + } + + return c.SendStatus(http.StatusOK) +} diff --git a/backend/entities/auth/base/models.go b/backend/entities/auth/base/models.go deleted file mode 100644 index 0657ac760..000000000 --- a/backend/entities/auth/base/models.go +++ /dev/null @@ -1,20 +0,0 @@ -package base - -type VerifyEmailRequestBody struct { - Email string `json:"email" validate:"required,email"` - Token string `json:"token" validate:"required,len=6"` -} - -type VerifyPasswordResetTokenRequestBody struct { - Token string `json:"token" validate:"required"` - NewPassword string `json:"new_password" validate:"required"` // MARK: must be validated manually - VerifyNewPassword string `json:"verify_new_password" validate:"required,eqfield=NewPassword"` // MARK: must be validated manually -} - -type EmailRequestBody struct { - Email string `json:"email" validate:"required,email"` -} - -type RefreshTokenCookieBody struct { - RefreshToken string `cookie:"refresh_token" validate:"required"` -} diff --git a/backend/entities/auth/base/routes.go b/backend/entities/auth/base/routes.go index 1e5c86744..f1df0ec6c 100644 --- a/backend/entities/auth/base/routes.go +++ b/backend/entities/auth/base/routes.go @@ -1,24 +1,42 @@ package base import ( - "github.com/GenerateNU/sac/backend/constants" - "github.com/GenerateNU/sac/backend/types" + "github.com/GenerateNU/sac/backend/integrations/email" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" ) -func Auth(params types.RouteParams) { - authController := NewAuthController(NewAuthService(params.ServiceParams)) +type Params struct { + authProvider soth.Provider + providers []soth.Provider + applicationURL string + router fiber.Router + db *gorm.DB +} - // api/v1/auth/* - auth := params.Router.Group("/auth") +func NewParams(authProvider soth.Provider, applicationURL string, router fiber.Router, db *gorm.DB, emailer email.Emailer, validate *validator.Validate, providers ...soth.Provider) Params { + return Params{ + authProvider: authProvider, + providers: providers, + applicationURL: applicationURL, + router: router, + db: db, + } +} - auth.Post("/register", authController.Register) - auth.Post("/logout", authController.Logout) - auth.Post("/login", authController.Login) - auth.Post("/refresh", authController.Refresh) +func Auth(params Params) { + soth.UseProviders( + append(params.providers, params.authProvider)..., + ) - auth.Post("/send-code", params.UtilityMiddleware.Limiter(constants.RATE_LIMIT_MAX_REQUESTS, constants.RATE_LIMIT_DURATION), authController.SendCode) - auth.Post("/verify-email", authController.VerifyEmail) + handler := Handler{db: params.db, authProvider: params.authProvider} - auth.Post("/forgot-password", params.UtilityMiddleware.Limiter(constants.RATE_LIMIT_MAX_REQUESTS, constants.RATE_LIMIT_DURATION), authController.ForgotPassword) - auth.Post("/verify-reset", authController.VerifyPasswordResetToken) + params.router.Route("/auth", func(r fiber.Router) { + r.Get("/login", handler.Login) + r.Get("/:provider", handler.Provider) + r.Get(":provider/callback", handler.ProviderCallback) + r.Get("/logout", handler.ProviderLogout) + }) } diff --git a/backend/entities/auth/base/service.go b/backend/entities/auth/base/service.go deleted file mode 100644 index c8a0c5415..000000000 --- a/backend/entities/auth/base/service.go +++ /dev/null @@ -1,461 +0,0 @@ -package base - -import ( - "context" - "errors" - "fmt" - "log/slog" - "time" - - "github.com/GenerateNU/sac/backend/auth" - "github.com/GenerateNU/sac/backend/constants" - authEntities "github.com/GenerateNU/sac/backend/entities/auth" - "github.com/GenerateNU/sac/backend/entities/models" - "github.com/GenerateNU/sac/backend/entities/users" - usersEntities "github.com/GenerateNU/sac/backend/entities/users/base" - - "github.com/GenerateNU/sac/backend/errs" - "github.com/GenerateNU/sac/backend/types" - - "github.com/GenerateNU/sac/backend/utilities" - - "github.com/golang-jwt/jwt" - "gorm.io/gorm" -) - -type AuthServiceInterface interface { - GetRole(id string) (*models.UserRole, error) - Register(ctx context.Context, userBody usersEntities.CreateUserRequestBody) (*models.User, *auth.Token, error) - Login(ctx context.Context, userBody authEntities.LoginResponseBody) (*models.User, *auth.Token, error) - Refresh(ctx context.Context, refreshToken string) (*auth.Token, error) - Logout(ctx context.Context, accessToken string, refreshToken string) error - - SendCode(email string) error - VerifyEmail(emailBody VerifyEmailRequestBody) error - - ForgotPassword(email string) error - VerifyPasswordResetToken(passwordBody VerifyPasswordResetTokenRequestBody) error -} - -type AuthService struct { - types.ServiceParams -} - -func NewAuthService(serviceParams types.ServiceParams) AuthServiceInterface { - return &AuthService{serviceParams} -} - -func (a *AuthService) Login(ctx context.Context, loginBody authEntities.LoginResponseBody) (*models.User, *auth.Token, error) { - if err := utilities.Validate(a.Validate, loginBody, *utilities.NewMaybeError("password", auth.ValidatePassword(loginBody.Password))); err != nil { - return nil, nil, err - } - - user, err := users.GetUserByEmail(a.DB, loginBody.Email) - if err != nil { - return nil, nil, err - } - - correct, err := auth.CompareHash(loginBody.Password, user.PasswordHash) - if err != nil || !correct { - return nil, nil, err - } - - 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, err - } - - storeRefreshTokenCtx, storeRefreshTokenCancel := context.WithTimeoutCause(ctx, constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer storeRefreshTokenCancel() - if err := a.Stores.ActiveToken.StoreRefreshToken(storeRefreshTokenCtx, string(tokens.RefreshToken), user.ID.String(), constants.REFRESH_TOKEN_EXPIRY); err != nil { - return nil, nil, err - } - - return user, tokens, nil -} - -func (a *AuthService) Register(ctx context.Context, userBody usersEntities.CreateUserRequestBody) (*models.User, *auth.Token, error) { - if err := utilities.Validate(a.Validate, userBody, *utilities.NewMaybeError("password", auth.ValidatePassword(userBody.Password))); err != nil { - return nil, nil, err - } - - user, err := utilities.MapJsonTags(userBody, &models.User{}) - if err != nil { - return nil, nil, err - } - - user.Role = models.Student - - passwordHash, err := auth.ComputeHash(userBody.Password) - if err != nil { - return nil, nil, err - } - - user.Email = utilities.NormalizeEmail(userBody.Email) - user.PasswordHash = *passwordHash - - if err := a.Integrations.Email.SendWelcomeEmail(fmt.Sprintf("%s %s", user.FirstName, user.LastName), user.Email); err != nil { - return nil, nil, fmt.Errorf("failed to send welcome email: %w", err) - } - - user, err = CreateUser(a.DB, user) - if err != nil { - return nil, nil, err - } - - if err := a.SendCode(user.Email); err != nil { - return nil, nil, err - } - - loginCtx, loginCancel := context.WithTimeoutCause(ctx, constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer loginCancel() - _, tokens, err := a.Login(loginCtx, authEntities.LoginResponseBody{Email: user.Email, Password: userBody.Password}) - if err != nil { - return nil, nil, err - } - - return user, tokens, nil -} - -func (a *AuthService) Refresh(ctx context.Context, refreshToken string) (*auth.Token, error) { - isActiveCtx, isActiveCancel := context.WithTimeoutCause(ctx, constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer isActiveCancel() - isActive, err := a.Stores.ActiveToken.IsActive(isActiveCtx, refreshToken) - if err != nil { - return nil, err - } - - if !isActive { - return nil, utilities.Unauthorized() - } - - isBlacklistedCtx, isBlacklistedCancel := context.WithTimeoutCause(ctx, constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer isBlacklistedCancel() - isblacklisted, err := a.Stores.Blacklist.IsTokenBlacklisted(isBlacklistedCtx, refreshToken) - if err != nil { - return nil, err - } - - if isblacklisted { - return nil, utilities.Unauthorized() - } - - claims, err := a.JWT.ExtractClaims(refreshToken, auth.RefreshToken) - if err != nil { - return nil, err - } - - role, err := a.GetRole(claims["iss"].(string)) - if err != nil { - return nil, err - } - - 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, err - } - - blackListCtx, blackListCancel := context.WithTimeoutCause(ctx, constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer blackListCancel() - if err := a.Stores.Blacklist.BlacklistToken(blackListCtx, refreshToken, time.Until(time.Unix(int64(claims["exp"].(float64)), 0))); err != nil { - return nil, err - } - - deleteRefreshTokenCtx, deleteRefreshTokenCancel := context.WithTimeoutCause(ctx, constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer deleteRefreshTokenCancel() - if err := a.Stores.ActiveToken.DeleteRefreshToken(deleteRefreshTokenCtx, refreshToken); err != nil { - return nil, err - } - - storeRefreshTokenCtx, storeRefreshTokenCancel := context.WithTimeoutCause(ctx, constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer storeRefreshTokenCancel() - if err := a.Stores.ActiveToken.StoreRefreshToken(storeRefreshTokenCtx, string(tokens.RefreshToken), claims["iss"].(string), constants.REFRESH_TOKEN_EXPIRY); err != nil { - return nil, err - } - - return tokens, nil -} - -func (a *AuthService) GetRole(id string) (*models.UserRole, error) { - idAsUUID, err := utilities.ValidateID(id) - if err != nil { - return nil, err - } - - user, err := users.GetUser(a.DB, *idAsUUID) - if err != nil { - return nil, err - } - - return &user.Role, nil -} - -func (a *AuthService) ForgotPassword(email string) error { - user, err := users.GetUserByEmail(a.DB, email) - if err != nil { - return nil - } - - activeToken, err := GetActiveTokenByUserID(a.DB, user.ID, models.PasswordResetType) - if err != nil { - if !utilities.IsNotFound(err) { - return err - } - } - - if activeToken != nil { - err := a.Integrations.Email.SendPasswordResetEmail(user.FirstName, user.Email, activeToken.Token) - if err != nil { - return err - } - - return nil - } - - token, err := auth.GenerateURLSafeToken(64) - if err != nil { - return err - } - - if err := SaveToken(a.DB, user.ID, *token, models.PasswordResetType, time.Now().Add(time.Hour*24).UTC()); err != nil { - return err - } - - if err := a.Integrations.Email.SendPasswordResetEmail(user.FirstName, user.Email, *token); err != nil { - return err - } - - return nil -} - -func (a *AuthService) VerifyPasswordResetToken(passwordBody VerifyPasswordResetTokenRequestBody) error { - if err := utilities.Validate(a.Validate, passwordBody); err != nil { - return err - } - - token, err := GetToken(a.DB, passwordBody.Token, models.PasswordResetType) - if err != nil { - return err - } - - if token.ExpiresAt.Before(time.Now().UTC()) { - return utilities.Unauthorized() - } - - hash, err := auth.ComputeHash(passwordBody.NewPassword) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - - tx := a.DB.Begin() - defer func() { - if r := recover(); r != nil { - tx.Rollback() - } - }() - - if err := users.UpdatePassword(tx, token.UserID, *hash); err != nil { - tx.Rollback() - return err - } - - if err := DeleteToken(tx, passwordBody.Token, models.PasswordResetType); err != nil { - tx.Rollback() - return err - } - - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return err - } - - return nil -} - -func (a *AuthService) SendCode(email string) error { - user, err := users.GetUserByEmail(a.DB, email) - if err != nil { - return err - } - - if user.IsVerified { - return fmt.Errorf("user is already verified") - } - - activeOTP, err := GetActiveTokenByUserID(a.DB, user.ID, models.EmailVerificationType) - if err != nil { - if !utilities.IsNotFound(err) { - return err - } - } - - if activeOTP != nil { - if err := a.Integrations.Email.SendEmailVerification(user.Email, activeOTP.Token); err != nil { - return err - } - - return nil - } - - otp, err := auth.GenerateOTP(constants.OTP_LENGTH) - if err != nil { - return fmt.Errorf("failed to generate OTP: %w", err) - } - - if err := SaveToken(a.DB, user.ID, *otp, models.EmailVerificationType, time.Now().Add(constants.OTP_EXPIRY).UTC()); err != nil { - return err - } - - if err := a.Integrations.Email.SendEmailVerification(user.Email, *otp); err != nil { - return err - } - - return nil -} - -func verifyEmailHelper(user *models.User, token string, db *gorm.DB) error { - if user.IsVerified { - return fmt.Errorf("user is already verified") - } - - otp, otpErr := GetToken(db, token, models.EmailVerificationType) - if otpErr != nil { - return otpErr - } - - if otp.Token != token { - slog.Error("invalid otp", "otp", otp.Token, "token", token) - return utilities.BadRequest(errors.New("invalid otp")) - } - - if otp.ExpiresAt.Before(time.Now().UTC()) { - return utilities.BadRequest(errors.New("otp expired")) - } - - return nil -} - -func (a *AuthService) VerifyEmail(emailBody VerifyEmailRequestBody) error { - if err := utilities.Validate(a.Validate, emailBody); err != nil { - return err - } - - user, err := users.GetUserByEmail(a.DB, emailBody.Email) - if err != nil { - return err - } - - if err := verifyEmailHelper(user, emailBody.Token, a.DB); err != nil { - return err - } - - tx := a.DB.Begin() - defer func() { - if r := recover(); r != nil { - tx.Rollback() - } - }() - - if err := users.UpdateEmailVerification(tx, user.ID); err != nil { - tx.Rollback() - return err - } - - if err := DeleteToken(tx, emailBody.Token, models.EmailVerificationType); err != nil { - tx.Rollback() - return err - } - - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return err - } - - return nil -} - -func (a *AuthService) Logout(ctx context.Context, accessToken string, refreshToken string) error { - if accessToken == "" { - return utilities.Unauthorized() - } - - token, err := utilities.SplitBearerToken(accessToken) - if err != nil { - return utilities.Unauthorized() - } - - blacklistAccessTokenCtx, blacklistAccessTokenCancel := context.WithTimeoutCause(ctx, constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer blacklistAccessTokenCancel() - if err = blacklist(blacklistAccessTokenCtx, a, token); err != nil { - return utilities.Unauthorized() - } - - if refreshToken == "" { - return utilities.Unauthorized() - } - - isActiveCtx, isActiveCancel := context.WithTimeoutCause(ctx, constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer isActiveCancel() - isActive, err := a.Stores.ActiveToken.IsActive(isActiveCtx, refreshToken) - if err != nil { - return utilities.Unauthorized() - } - - if !isActive { - return utilities.Unauthorized() - } - - blacklistRefreshTokenCtx, blacklistRefreshTokenCancel := context.WithTimeoutCause(ctx, constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer blacklistRefreshTokenCancel() - if err := blacklist(blacklistRefreshTokenCtx, a, &refreshToken); err != nil { - return utilities.Unauthorized() - } - - deleteRefreshTokenCtx, deleteRefreshTokenCancel := context.WithTimeoutCause(ctx, constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer deleteRefreshTokenCancel() - if err := a.Stores.ActiveToken.DeleteRefreshToken(deleteRefreshTokenCtx, refreshToken); err != nil { - return err - } - - return nil -} - -func blacklist(ctx context.Context, a *AuthService, token *string) error { - claims, err := a.JWT.ExtractClaims(*token, auth.AccessToken) - if err != nil { - return err - } else { - exp, ok := claims["exp"].(float64) - if ok { - _ = a.Stores.Blacklist.BlacklistToken(ctx, *token, time.Until(time.Unix(int64(exp), 0))) - } - } - - return nil -} diff --git a/backend/entities/auth/base/transactions.go b/backend/entities/auth/base/transactions.go index a8812c280..1dd839c52 100644 --- a/backend/entities/auth/base/transactions.go +++ b/backend/entities/auth/base/transactions.go @@ -1,69 +1,47 @@ package base import ( + "context" "errors" - "time" "github.com/GenerateNU/sac/backend/entities/models" - "github.com/GenerateNU/sac/backend/utilities" - "github.com/google/uuid" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" "gorm.io/gorm" ) -func CreateUser(db *gorm.DB, user *models.User) (*models.User, error) { - if err := db.Create(user).Error; err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return nil, utilities.ErrDuplicate - } - return nil, err - } - - return user, nil -} - -func GetToken(db *gorm.DB, token string, tokenType models.VerificationType) (*models.Verification, error) { - tokenModel := models.Verification{} - if err := db.Where("token = ? AND type = ?", token, tokenType).First(&tokenModel).Error; err != nil { +func FindOrCreateUser(ctx context.Context, db *gorm.DB, user soth.User) (*models.User, error) { + var sacUser models.User + if err := db.WithContext(ctx).Where("email = ?", user.Email).First(&sacUser).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, utilities.ErrNotFound + if err := createUser(ctx, db, user.Into()); err != nil { + return nil, err + } + } else { + return nil, err } - return nil, err } - return &tokenModel, nil + return &sacUser, nil } -func GetActiveTokenByUserID(db *gorm.DB, userID uuid.UUID, tokenType models.VerificationType) (*models.Verification, error) { - token := models.Verification{} - if err := db.Where("user_id = ? AND expires_at > ? AND type = ?", userID, time.Now().UTC(), tokenType).First(&token).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, utilities.ErrNotFound +func createUser(ctx context.Context, db *gorm.DB, user *models.User) error { + tx := db.WithContext(ctx).Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() } - return nil, err - } + }() - return &token, nil -} - -func DeleteToken(db *gorm.DB, token string, tokenType models.VerificationType) error { - if err := db.Where("token = ? AND type = ?", token, tokenType).Delete(&models.Verification{}).Error; err != nil { + if err := tx.Create(user).Error; err != nil { + tx.Rollback() return err } - return nil -} - -func SaveToken(db *gorm.DB, userID uuid.UUID, token string, tokenType models.VerificationType, expiry time.Time) error { - tokenModel := models.Verification{ - UserID: userID, - Token: token, - ExpiresAt: expiry, - Type: tokenType, - } - - if err := db.Create(&tokenModel).Error; err != nil { + welcomeTask := models.WelcomeTask{Name: user.Name, Email: user.Email} + if err := tx.Create(&welcomeTask).Error; err != nil { + tx.Rollback() return err } - return nil + return tx.Commit().Error } diff --git a/backend/entities/categories/base/routes.go b/backend/entities/categories/base/routes.go index f41980ee0..a9458cb68 100644 --- a/backend/entities/categories/base/routes.go +++ b/backend/entities/categories/base/routes.go @@ -1,8 +1,8 @@ package base import ( - "github.com/GenerateNU/sac/backend/auth" "github.com/GenerateNU/sac/backend/entities/categories/tags" + "github.com/GenerateNU/sac/backend/permission" "github.com/GenerateNU/sac/backend/types" "github.com/gofiber/fiber/v2" ) @@ -22,15 +22,15 @@ func Category(categoryParams types.RouteParams) fiber.Router { // api/v1/categories/* categories := categoryParams.Router.Group("/categories") - categories.Post("/", categoryParams.AuthMiddleware.Authorize(auth.CreateAll), categoryController.CreateCategory) + categories.Post("/", categoryParams.AuthMiddleware.Authorize(permission.CreateAll), categoryController.CreateCategory) categories.Get("/", categoryParams.UtilityMiddleware.Paginator, categoryController.GetCategories) // api/v1/categories/:categoryID/* categoryID := categories.Group("/:categoryID") categoryID.Get("/", categoryController.GetCategory) - categoryID.Delete("/", categoryParams.AuthMiddleware.Authorize(auth.DeleteAll), categoryController.DeleteCategory) - categoryID.Patch("/", categoryParams.AuthMiddleware.Authorize(auth.WriteAll), categoryController.UpdateCategory) + categoryID.Delete("/", categoryParams.AuthMiddleware.Authorize(permission.DeleteAll), categoryController.DeleteCategory) + categoryID.Patch("/", categoryParams.AuthMiddleware.Authorize(permission.WriteAll), categoryController.UpdateCategory) return categoryID } diff --git a/backend/entities/clubs/base/controller.go b/backend/entities/clubs/base/controller.go index ae6e52bcc..a772c6b6b 100644 --- a/backend/entities/clubs/base/controller.go +++ b/backend/entities/clubs/base/controller.go @@ -66,12 +66,12 @@ func (cl *ClubController) CreateClub(c *fiber.Ctx) error { return utilities.InvalidJSON() } - userID, err := locals.UserIDFrom(c) + user, err := locals.UserFrom(c) if err != nil { return err } - club, err := cl.clubService.CreateClub(*userID, clubBody) + club, err := cl.clubService.CreateClub(user.ID, clubBody) if err != nil { return err } diff --git a/backend/entities/clubs/base/routes.go b/backend/entities/clubs/base/routes.go index d90ffd0e5..c3d234be9 100644 --- a/backend/entities/clubs/base/routes.go +++ b/backend/entities/clubs/base/routes.go @@ -1,7 +1,6 @@ package base import ( - p "github.com/GenerateNU/sac/backend/auth" "github.com/GenerateNU/sac/backend/entities/clubs/events" "github.com/GenerateNU/sac/backend/entities/clubs/followers" "github.com/GenerateNU/sac/backend/entities/clubs/leadership" @@ -10,6 +9,7 @@ import ( "github.com/GenerateNU/sac/backend/entities/clubs/socials" "github.com/GenerateNU/sac/backend/entities/clubs/tags" authMiddleware "github.com/GenerateNU/sac/backend/middleware/auth" + "github.com/GenerateNU/sac/backend/permission" "github.com/GenerateNU/sac/backend/types" "github.com/gofiber/fiber/v2" @@ -34,7 +34,7 @@ func ClubRouter(clubParams types.RouteParams) fiber.Router { clubs := clubParams.Router.Group("/clubs") clubs.Get("/", clubParams.UtilityMiddleware.Paginator, clubController.GetClubs) - clubs.Post("/", clubParams.AuthMiddleware.Authorize(p.CreateAll), clubController.CreateClub) + clubs.Post("/", clubParams.AuthMiddleware.Authorize(permission.CreateAll), clubController.CreateClub) // api/v1/clubs/:clubID/* clubID := clubs.Group("/:clubID") @@ -45,7 +45,7 @@ func ClubRouter(clubParams types.RouteParams) fiber.Router { authMiddleware.AttachExtractor(clubParams.AuthMiddleware.ClubAuthorizeById, authMiddleware.ExtractFromParams("clubID")), clubController.UpdateClub, ) - clubID.Delete("/", clubParams.AuthMiddleware.Authorize(p.DeleteAll), clubController.DeleteClub) + clubID.Delete("/", clubParams.AuthMiddleware.Authorize(permission.DeleteAll), clubController.DeleteClub) return clubID } diff --git a/backend/entities/clubs/base/transactions.go b/backend/entities/clubs/base/transactions.go index 65c924e07..0cb853e94 100644 --- a/backend/entities/clubs/base/transactions.go +++ b/backend/entities/clubs/base/transactions.go @@ -2,7 +2,6 @@ package base import ( "errors" - "log/slog" "github.com/GenerateNU/sac/backend/constants" "github.com/GenerateNU/sac/backend/utilities" @@ -48,7 +47,6 @@ func CreateClub(db *gorm.DB, userId uuid.UUID, club models.Club) (*models.Club, if err := tx.Create(&club).Error; err != nil { tx.Rollback() - slog.Info("err in create club", "err", err) return nil, err } @@ -60,7 +58,6 @@ func CreateClub(db *gorm.DB, userId uuid.UUID, club models.Club) (*models.Club, if err := tx.Create(&membership).Error; err != nil { tx.Rollback() - slog.Info("err in create membership", "err", err) return nil, err } diff --git a/backend/entities/clubs/recruitment/controller.go b/backend/entities/clubs/recruitment/controller.go index cf321d1f2..3d04efc79 100644 --- a/backend/entities/clubs/recruitment/controller.go +++ b/backend/entities/clubs/recruitment/controller.go @@ -22,7 +22,7 @@ func (cr *ClubRecruitmentController) CreateClubRecruitment(c *fiber.Ctx) error { return utilities.InvalidJSON() } - recruitment, err := cr.clubRecruitmentService.CreateClubRecruitment(c.UserContext(), c.Params("clubID"), body) + recruitment, err := cr.clubRecruitmentService.CreateClubRecruitment(c.Context(), c.Params("clubID"), body) if err != nil { return err } @@ -31,7 +31,7 @@ func (cr *ClubRecruitmentController) CreateClubRecruitment(c *fiber.Ctx) error { } func (cr *ClubRecruitmentController) GetClubRecruitment(c *fiber.Ctx) error { - recruitment, err := cr.clubRecruitmentService.GetClubRecruitment(c.UserContext(), c.Params("clubID")) + recruitment, err := cr.clubRecruitmentService.GetClubRecruitment(c.Context(), c.Params("clubID")) if err != nil { return err } @@ -45,7 +45,7 @@ func (cr *ClubRecruitmentController) UpdateClubRecruitment(c *fiber.Ctx) error { return utilities.InvalidJSON() } - recruitment, err := cr.clubRecruitmentService.UpdateClubRecruitment(c.UserContext(), c.Params("clubID"), body) + recruitment, err := cr.clubRecruitmentService.UpdateClubRecruitment(c.Context(), c.Params("clubID"), body) if err != nil { return err } @@ -54,7 +54,7 @@ func (cr *ClubRecruitmentController) UpdateClubRecruitment(c *fiber.Ctx) error { } func (cr *ClubRecruitmentController) DeleteClubRecruitment(c *fiber.Ctx) error { - if err := cr.clubRecruitmentService.DeleteClubRecruitment(c.UserContext(), c.Params("clubID")); err != nil { + if err := cr.clubRecruitmentService.DeleteClubRecruitment(c.Context(), c.Params("clubID")); err != nil { return err } @@ -67,7 +67,7 @@ func (cr *ClubRecruitmentController) CreateClubRecruitmentApplication(c *fiber.C return utilities.InvalidJSON() } - application, err := cr.clubRecruitmentService.CreateClubRecruitmentApplication(c.UserContext(), c.Params("clubID"), body) + application, err := cr.clubRecruitmentService.CreateClubRecruitmentApplication(c.Context(), c.Params("clubID"), body) if err != nil { return err } @@ -81,7 +81,7 @@ func (cr *ClubRecruitmentController) GetClubRecruitmentApplications(c *fiber.Ctx return utilities.ErrExpectedPageInfo } - applications, err := cr.clubRecruitmentService.GetClubRecruitmentApplications(c.UserContext(), c.Params("clubID"), *pageInfo) + applications, err := cr.clubRecruitmentService.GetClubRecruitmentApplications(c.Context(), c.Params("clubID"), *pageInfo) if err != nil { return err } @@ -90,7 +90,7 @@ func (cr *ClubRecruitmentController) GetClubRecruitmentApplications(c *fiber.Ctx } func (cr *ClubRecruitmentController) GetClubRecruitmentApplication(c *fiber.Ctx) error { - application, err := cr.clubRecruitmentService.GetClubRecruitmentApplication(c.UserContext(), c.Params("clubID"), c.Params("applicationID")) + application, err := cr.clubRecruitmentService.GetClubRecruitmentApplication(c.Context(), c.Params("clubID"), c.Params("applicationID")) if err != nil { return err } @@ -104,7 +104,7 @@ func (cr *ClubRecruitmentController) UpdateClubRecruitmentApplication(c *fiber.C return utilities.InvalidJSON() } - application, err := cr.clubRecruitmentService.UpdateClubRecruitmentApplication(c.UserContext(), c.Params("clubID"), c.Params("applicationID"), body) + application, err := cr.clubRecruitmentService.UpdateClubRecruitmentApplication(c.Context(), c.Params("clubID"), c.Params("applicationID"), body) if err != nil { return err } @@ -113,7 +113,7 @@ func (cr *ClubRecruitmentController) UpdateClubRecruitmentApplication(c *fiber.C } func (cr *ClubRecruitmentController) DeleteClubRecruitmentApplication(c *fiber.Ctx) error { - if err := cr.clubRecruitmentService.DeleteClubRecruitmentApplication(c.UserContext(), c.Params("clubID"), c.Params("applicationID")); err != nil { + if err := cr.clubRecruitmentService.DeleteClubRecruitmentApplication(c.Context(), c.Params("clubID"), c.Params("applicationID")); err != nil { return err } diff --git a/backend/entities/models/oauth.go b/backend/entities/models/oauth.go deleted file mode 100644 index acb736504..000000000 --- a/backend/entities/models/oauth.go +++ /dev/null @@ -1,35 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type OAuthResource string - -const ( - Google OAuthResource = "google" - Outlook OAuthResource = "outlook" -) - -type UserOAuthTokens struct { - UserID uuid.UUID `json:"user_id" validate:"required,uuid4"` - RefreshToken string `json:"refresh_token" validate:"max=255"` - AccessToken string `json:"access_token" validate:"max=255"` - CSRFToken string `json:"csrf_token" validate:"max=255"` - ResourceType OAuthResource `json:"resource_type" validate:"required"` - ExpiresAt time.Time `json:"expires_at" validate:"required"` -} - -type OAuthToken struct { - AccessToken string `json:"access_token" validate:"required"` - ExpiresIn int `json:"expires_in" validate:"required"` - RefreshToken string `json:"refresh_token" validate:"required"` - Scope string `json:"scope" validate:"required"` - TokenType string `json:"token_type" validate:"required"` -} - -func (UserOAuthTokens) TableName() string { - return "user_oauth_tokens" -} diff --git a/backend/entities/models/tokens.go b/backend/entities/models/tokens.go deleted file mode 100644 index b1b527f76..000000000 --- a/backend/entities/models/tokens.go +++ /dev/null @@ -1,6 +0,0 @@ -package models - -type Tokens struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` -} diff --git a/backend/entities/models/user.go b/backend/entities/models/user.go index bf1bb3b1e..a3b166fd9 100644 --- a/backend/entities/models/user.go +++ b/backend/entities/models/user.go @@ -1,24 +1,29 @@ package models import ( + "encoding/gob" + + go_json "github.com/goccy/go-json" "gorm.io/gorm" ) +func init() { + gob.Register(User{}) +} + type User struct { Model Role UserRole `json:"role"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + Name string `json:"name"` Email string `json:"email"` - PasswordHash string `json:"-"` Major0 Major `json:"major0"` Major1 Major `json:"major1"` Major2 Major `json:"major2"` College College `json:"college"` GraduationCycle GraduationCycle `json:"graduation_cycle"` GraduationYear int16 `json:"graduation_year"` - IsVerified bool `json:"is_verified"` + AvatarURL string `json:"avatar_url"` Tag []Tag `gorm:"many2many:user_tags;" json:"-"` Admin []Club `gorm:"many2many:user_club_admins;" json:"-"` @@ -31,6 +36,23 @@ type User struct { Waitlist []Event `gorm:"many2many:user_event_waitlists;" json:"-"` } +func (u *User) Marshal() (*string, error) { + user, err := go_json.Marshal(u) + if err != nil { + return nil, err + } + strUser := string(user) + return &strUser, nil +} + +func UnmarshalUser(user string) *User { + u := User{} + if err := go_json.Unmarshal([]byte(user), &u); err != nil { + return nil + } + return &u +} + func (u *User) AfterCreate(tx *gorm.DB) (err error) { sac := &Club{} if err := tx.Where("name = ?", "SAC").First(sac).Error; err != nil { diff --git a/backend/entities/models/verification.go b/backend/entities/models/verification.go deleted file mode 100644 index 77546ec05..000000000 --- a/backend/entities/models/verification.go +++ /dev/null @@ -1,21 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type VerificationType string - -const ( - EmailVerificationType VerificationType = "email_verification" - PasswordResetType VerificationType = "password_reset" -) - -type Verification struct { - UserID uuid.UUID `json:"user_id"` - Token string `json:"token"` - ExpiresAt time.Time `json:"expires_at"` - Type VerificationType `json:"type"` -} diff --git a/backend/entities/models/welcome_task.go b/backend/entities/models/welcome_task.go new file mode 100644 index 000000000..fe4cce905 --- /dev/null +++ b/backend/entities/models/welcome_task.go @@ -0,0 +1,7 @@ +package models + +type WelcomeTask struct { + Email string `gorm:"primaryKey"` + Name string + Attempts int +} diff --git a/backend/entities/oauth/base/controller.go b/backend/entities/oauth/base/controller.go deleted file mode 100644 index 4c0343e41..000000000 --- a/backend/entities/oauth/base/controller.go +++ /dev/null @@ -1,90 +0,0 @@ -package base - -import ( - "errors" - "net/http" - - "github.com/GenerateNU/sac/backend/entities/models" - "github.com/GenerateNU/sac/backend/locals" - - "github.com/gofiber/fiber/v2" -) - -type OAuthController struct { - OAuthService OAuthServiceInterface -} - -func NewOAuthController(oauthService OAuthServiceInterface) *OAuthController { - return &OAuthController{OAuthService: oauthService} -} - -func (oc *OAuthController) Authorize(c *fiber.Ctx) error { - // Extract the resource type from the query params: - resourceType := models.OAuthResource(c.Query("type")) - if resourceType == "" { - return errors.New("resource type is required") - } - - // Extract the user making the call: - userID, err := locals.UserIDFrom(c) - if err != nil { - return err - } - - // Call the respective authorize method: - authUrl, err := oc.OAuthService.Authorize(*userID, resourceType) - if err != nil { - return err - } - - return c.Status(http.StatusOK).JSON(*authUrl) -} - -func (oc *OAuthController) Token(c *fiber.Ctx) error { - // Extract the resource type from the query params: - resourceType := models.OAuthResource(c.Query("type")) - if resourceType == "" { - return errors.New("resource type is required") - } - - // Parse the body of the request: - var tokenBody OAuthTokenRequestBody - if err := c.BodyParser(&tokenBody); err != nil { - return err - } - - // Extract the user making the call: - userID, err := locals.UserIDFrom(c) - if err != nil { - return err - } - - // Call the respective token method: - err = oc.OAuthService.Token(*userID, tokenBody, resourceType) - if err != nil { - return err - } - - return c.SendStatus(http.StatusNoContent) -} - -func (oc *OAuthController) Revoke(c *fiber.Ctx) error { - // Extract the resource type from the query params: - resourceType := models.OAuthResource(c.Query("type")) - if resourceType == "" { - return errors.New("resource type is required") - } - - // Extract the user making the call: - userID, err := locals.UserIDFrom(c) - if err != nil { - return err - } - - err = oc.OAuthService.Revoke(*userID, resourceType) - if err != nil { - return err - } - - return c.Status(http.StatusOK).JSON("Successfully revoked token") -} diff --git a/backend/entities/oauth/base/models.go b/backend/entities/oauth/base/models.go deleted file mode 100644 index ae04dca26..000000000 --- a/backend/entities/oauth/base/models.go +++ /dev/null @@ -1,6 +0,0 @@ -package base - -type OAuthTokenRequestBody struct { - Code string `json:"code" validate:"omitempty"` - State string `json:"state" validate:"omitempty"` -} diff --git a/backend/entities/oauth/base/routes.go b/backend/entities/oauth/base/routes.go deleted file mode 100644 index fc0971e22..000000000 --- a/backend/entities/oauth/base/routes.go +++ /dev/null @@ -1,14 +0,0 @@ -package base - -import "github.com/GenerateNU/sac/backend/types" - -func OAuth(params types.RouteParams) { - oauthController := NewOAuthController(&OAuthService{ServiceParams: params.ServiceParams}) - - // api/v1/calendar/* - calendar := params.Router.Group("/oauth") - - calendar.Get("/authorize", oauthController.Authorize) - calendar.Post("/token", oauthController.Token) - calendar.Delete("/revoke", oauthController.Revoke) -} diff --git a/backend/entities/oauth/base/service.go b/backend/entities/oauth/base/service.go deleted file mode 100644 index 7ff7ddd68..000000000 --- a/backend/entities/oauth/base/service.go +++ /dev/null @@ -1,194 +0,0 @@ -package base - -import ( - "errors" - "fmt" - "io" - "net/http" - "net/url" - - go_json "github.com/goccy/go-json" - - "github.com/GenerateNU/sac/backend/auth" - "github.com/GenerateNU/sac/backend/constants" - "github.com/GenerateNU/sac/backend/entities/models" - "github.com/GenerateNU/sac/backend/integrations/oauth" - "github.com/GenerateNU/sac/backend/types" - "github.com/GenerateNU/sac/backend/utilities" - "github.com/google/uuid" -) - -type OAuthServiceInterface interface { - Authorize(userID uuid.UUID, resource models.OAuthResource) (*string, error) - Token(userID uuid.UUID, tokenBody OAuthTokenRequestBody, resource models.OAuthResource) error - Revoke(userID uuid.UUID, resource models.OAuthResource) error -} - -type OAuthService struct { - ServiceParams types.ServiceParams -} - -func New(serviceParams types.ServiceParams) OAuthServiceInterface { - return &OAuthService{ - ServiceParams: serviceParams, - } -} - -func (e *OAuthService) Authorize(userID uuid.UUID, resource models.OAuthResource) (*string, error) { - // Parse the resource client: - client, err := e.parseResourceClient(resource) - if err != nil { - return nil, err - } - - // Create a CSRF Token: - csrfToken, err := auth.GenerateURLSafeToken(constants.CSRF_TOKEN_LENGTH) - if err != nil { - return nil, err - } - - // Save the CSRF Token to the database: - if err := CreateOAuthToken(e.ServiceParams.DB, - models.UserOAuthTokens{ - UserID: userID, - CSRFToken: *csrfToken, - ResourceType: resource, - }); err != nil { - return nil, err - } - - // Retrieve the authorize URL: - authorizeURL := client.ResourceClient.AuthorizeURL(*csrfToken) - - return &authorizeURL, nil -} - -func (e *OAuthService) Token(userID uuid.UUID, tokenBody OAuthTokenRequestBody, resource models.OAuthResource) error { - // Parse the resource client: - client, err := e.parseResourceClient(resource) - if err != nil { - return err - } - - // Retrieve the CSRF Token from the database: - oauthToken, err := GetOAuthToken(e.ServiceParams.DB, userID, resource) - if err != nil { - return err - } - - // Validate the CSRF Token: - if oauthToken.CSRFToken != tokenBody.State || oauthToken.CSRFToken == "" { - return errors.New("invalid CSRF token") - } - - // Exchange the code for an access token: - token, err := e.tokenExchange(client, tokenBody.Code) - if err != nil { - return err - } - - // Save the refresh token to the database: - if err := SaveOAuthTokens(e.ServiceParams.DB, userID, *token, resource); err != nil { - return err - } - - return nil -} - -func (e *OAuthService) Revoke(userID uuid.UUID, resource models.OAuthResource) error { - // Parse the resource client: - client, err := e.parseResourceClient(resource) - if err != nil { - return err - } - - // Retrieve access token: - oauthToken, err := GetOAuthToken(e.ServiceParams.DB, userID, resource) - if err != nil { - return err - } - - if oauthToken.AccessToken == "" { - return errors.New("user does not have an access token") - } - - // Revoke the token on resource server: - if err := client.ResourceClient.Revoke(oauthToken.AccessToken); err != nil { - return err - } - - // Remove refresh token from the database: - if err := DeleteOAuthToken(e.ServiceParams.DB, userID, resource); err != nil { - return err - } - - return nil -} - -func (e *OAuthService) parseResourceClient(resourceType models.OAuthResource) (*oauth.OAuthClient, error) { - var resourceClient oauth.OAuthResourceClientInterface - switch resourceType { - case "google": - resourceClient = &e.ServiceParams.Integrations.OAuth.Google - case "outlook": - resourceClient = &e.ServiceParams.Integrations.OAuth.Outlook - default: - return nil, errors.New("invalid resource type") - } - - return &oauth.OAuthClient{ - ResourceClient: resourceClient, - }, nil -} - -func (e *OAuthService) tokenExchange(client *oauth.OAuthClient, code string) (*models.OAuthToken, error) { - // Retrieve the client config - config := client.ResourceClient.GetConfig() - tokenUrl := fmt.Sprintf("%s/token", config.TokenURL) - - // Create the form data: - formData := url.Values{ - "client_id": {config.ClientID.Expose()}, - "client_secret": {config.ClientSecret.Expose()}, - "code": {code}, - "redirect_uri": {config.RedirectURI}, - "grant_type": {"authorization_code"}, - } - - encodedData := formData.Encode() - - resp, err := utilities.Request(http.MethodPost, tokenUrl, []byte(encodedData), utilities.FormURLEncoded()) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if !utilities.IsOk(resp.StatusCode) { - return nil, errors.New("failed to exchange code for token") - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - // Parse the data: - var oauthToken models.OAuthToken - if err = go_json.Unmarshal(body, &oauthToken); err != nil { - return nil, err - } - - if err := utilities.Validate(e.ServiceParams.Validate, oauthToken); err != nil { - return nil, err - } - - if oauthToken.AccessToken == "" { - return nil, errors.New("access token is empty") - } - - if oauthToken.RefreshToken == "" { - return nil, errors.New("refresh token is empty") - } - - return &oauthToken, nil -} diff --git a/backend/entities/oauth/base/transactions.go b/backend/entities/oauth/base/transactions.go deleted file mode 100644 index f2433369d..000000000 --- a/backend/entities/oauth/base/transactions.go +++ /dev/null @@ -1,59 +0,0 @@ -package base - -import ( - "errors" - "time" - - "github.com/GenerateNU/sac/backend/entities/models" - "github.com/GenerateNU/sac/backend/utilities" - "github.com/google/uuid" - "gorm.io/gorm" -) - -func CreateOAuthToken(db *gorm.DB, oauthToken models.UserOAuthTokens) error { - if err := db.Where("user_id = ? AND resource_type = ?", oauthToken.UserID, oauthToken.ResourceType).Save(&oauthToken).Error; err != nil { - return err - } - - return nil -} - -func GetOAuthToken(db *gorm.DB, userID uuid.UUID, resourceType models.OAuthResource) (*models.UserOAuthTokens, error) { - var oauthToken models.UserOAuthTokens - - if err := db.Where("user_id = ? AND resource_type = ?", userID, resourceType).First(&oauthToken).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, utilities.ErrNotFound - } - return nil, err - } - - return &oauthToken, nil -} - -func SaveOAuthTokens(db *gorm.DB, userID uuid.UUID, tokens models.OAuthToken, resourceType models.OAuthResource) error { - oauthToken := models.UserOAuthTokens{ - UserID: userID, - AccessToken: tokens.AccessToken, - RefreshToken: tokens.RefreshToken, - ExpiresAt: time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second), - ResourceType: resourceType, - } - - if err := db.Where("user_id = ? AND resource_type = ?", userID, resourceType).Save(&oauthToken).Error; err != nil { - return err - } - - return nil -} - -func DeleteOAuthToken(db *gorm.DB, userID uuid.UUID, resourceType models.OAuthResource) error { - if err := db.Where("user_id = ? AND resource_type = ?", userID, resourceType).Delete(&models.UserOAuthTokens{}).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return utilities.ErrNotFound - } - return err - } - - return nil -} diff --git a/backend/entities/socials/base/routes.go b/backend/entities/socials/base/routes.go index 2b0187240..3694aeb56 100644 --- a/backend/entities/socials/base/routes.go +++ b/backend/entities/socials/base/routes.go @@ -1,7 +1,7 @@ package base import ( - "github.com/GenerateNU/sac/backend/auth" + "github.com/GenerateNU/sac/backend/permission" "github.com/GenerateNU/sac/backend/types" ) @@ -13,5 +13,5 @@ func Social(socialParams types.RouteParams) { socials.Get("/", socialParams.UtilityMiddleware.Paginator, socialController.GetSocials) socials.Get("/:socialID", socialController.GetSocial) - socials.Delete("/:socialID", socialParams.AuthMiddleware.Authorize(auth.DeleteAll), socialController.DeleteSocial) + socials.Delete("/:socialID", socialParams.AuthMiddleware.Authorize(permission.DeleteAll), socialController.DeleteSocial) } diff --git a/backend/entities/tags/base/routes.go b/backend/entities/tags/base/routes.go index ca2050f20..1b4510baa 100644 --- a/backend/entities/tags/base/routes.go +++ b/backend/entities/tags/base/routes.go @@ -1,7 +1,7 @@ package base import ( - p "github.com/GenerateNU/sac/backend/auth" + "github.com/GenerateNU/sac/backend/permission" "github.com/GenerateNU/sac/backend/types" ) @@ -11,11 +11,11 @@ func Tag(tagParams types.RouteParams) { tags := tagParams.Router.Group("/tags") tags.Get("/", tagController.GetTags) - tags.Post("/", tagParams.AuthMiddleware.Authorize(p.CreateAll), tagController.CreateTag) + tags.Post("/", tagParams.AuthMiddleware.Authorize(permission.CreateAll), tagController.CreateTag) tagID := tags.Group("/:tagID") tagID.Get("/", tagController.GetTag) - tagID.Patch("/", tagParams.AuthMiddleware.Authorize(p.WriteAll), tagController.UpdateTag) - tagID.Delete("/", tagParams.AuthMiddleware.Authorize(p.DeleteAll), tagController.DeleteTag) + tagID.Patch("/", tagParams.AuthMiddleware.Authorize(permission.WriteAll), tagController.UpdateTag) + tagID.Delete("/", tagParams.AuthMiddleware.Authorize(permission.DeleteAll), tagController.DeleteTag) } diff --git a/backend/entities/users/base/controller.go b/backend/entities/users/base/controller.go index 202387350..f36aa84fc 100644 --- a/backend/entities/users/base/controller.go +++ b/backend/entities/users/base/controller.go @@ -3,8 +3,6 @@ package base import ( "net/http" - authEntities "github.com/GenerateNU/sac/backend/entities/auth" - "github.com/GenerateNU/sac/backend/locals" "github.com/GenerateNU/sac/backend/utilities" "github.com/garrettladley/fiberpaginate" @@ -47,35 +45,6 @@ func (u *UserController) GetUsers(c *fiber.Ctx) error { return c.Status(http.StatusOK).JSON(&users) } -// 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} error -// @Failure 401 {object} error -// @Failure 404 {object} error -// @Failure 500 {object} error -// @Router /auth/me [get] -func (u *UserController) GetMe(c *fiber.Ctx) error { - userID, err := locals.UserIDFrom(c) - if err != nil { - return err - } - - user, err := u.userService.GetMe(*userID) - if err != nil { - return err - } - - return c.Status(http.StatusOK).JSON(user) -} - // GetUser godoc // // @Summary Retrieve a user @@ -130,37 +99,6 @@ func (u *UserController) UpdateUser(c *fiber.Ctx) error { return c.Status(http.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 authEntities.UpdatePasswordRequestBody true "Password Body" -// @Success 200 {string} utilities.SuccessResponse -// @Failure 400 {object} error -// @Failure 401 {object} error -// @Failure 404 {object} error -// @Failure 500 {object} error -// @Router /users/{userID}/password [patch] -func (u *UserController) UpdatePassword(c *fiber.Ctx) error { - var passwordBody authEntities.UpdatePasswordRequestBody - - if err := c.BodyParser(&passwordBody); err != nil { - return utilities.InvalidJSON() - } - - err := u.userService.UpdatePassword(c.Params("userID"), passwordBody) - if err != nil { - return err - } - - return utilities.FiberMessage(c, http.StatusOK, "success") -} - // DeleteUser godoc // // @Summary Delete a user diff --git a/backend/entities/users/base/models.go b/backend/entities/users/base/models.go index d145e8993..44423bc56 100644 --- a/backend/entities/users/base/models.go +++ b/backend/entities/users/base/models.go @@ -2,23 +2,8 @@ package base import "github.com/GenerateNU/sac/backend/entities/models" -type CreateUserRequestBody struct { - 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,max=255"` // MARK: must be validated manually - // Optional fields - Major0 models.Major `json:"major0" validate:"not_equal_if_not_empty=Major1,not_equal_if_not_empty=Major2,omitempty,max=255,oneof=africanaStudies americanSignLanguage americanSignLanguage-EnglishInterpreting appliedPhysics architecturalStudies architecture art:ArtVisualStudies behavioralNeuroscience biochemistry bioengineering biology biomedicalPhysics businessAdministration businessAdministration:Accounting businessAdministration:AccountingAndAdvisoryServices businessAdministration:BrandManagement businessAdministration:BusinessAnalytics businessAdministration:CorporateInnovation businessAdministration:EntrepreneurialStartups businessAdministration:FamilyBusiness businessAdministration:Finance businessAdministration:Fintech businessAdministration:HealthcareManagementAndConsulting businessAdministration:Management businessAdministration:ManagementInformationSystems businessAdministration:Marketing businessAdministration:MarketingAnalytics businessAdministration:SocialInnovationAndEntrepreneurship businessAdministration:SupplyChainManagement cellAndMolecularBiology chemicalEngineering chemistry civilEngineering communicationStudies computerEngineering computerScience computingAndLaw criminologyAndCriminalJustice culturalAnthropology cybersecurity dataScience design economics electricalEngineering english environmentalAndSustainabilityStudies environmentalEngineering environmentalScience environmentalStudies gameArtAndAnimation gameDesign globalAsianStudies healthScience history historyCultureAndLaw humanServices industrialEngineering internationalAffairs internationalBusiness internationalBusiness:Accounting internationalBusiness:AccountingAndAdvisoryServices internationalBusiness:BrandManagement internationalBusiness:BusinessAnalytics internationalBusiness:CorporateInnovation internationalBusiness:EntrepreneurialStartups internationalBusiness:FamilyBusiness internationalBusiness:Finance internationalBusiness:Fintech internationalBusiness:HealthcareManagementAndConsulting internationalBusiness:Management internationalBusiness:ManagementInformationSystems internationalBusiness:Marketing internationalBusiness:MarketingAnalytics internationalBusiness:SocialInnovationAndEntrepreneurship internationalBusiness:SupplyChainManagement journalism landscapeArchitecture linguistics marineBiology mathematics mechanicalEngineering mediaAndScreenStudies mediaArts music musicTechnology nursing pharmaceuticalSciences pharmacy(PharmD) philosophy physics politicalScience politicsPhilosophyEconomics psychology publicHealth publicRelations religiousStudies sociology spanish speechLanguagePathologyAndAudiology theatre"` - Major1 models.Major `json:"major1" validate:"not_equal_if_not_empty=Major0,not_equal_if_not_empty=Major2,omitempty,max=255,oneof=africanaStudies americanSignLanguage americanSignLanguage-EnglishInterpreting appliedPhysics architecturalStudies architecture art:ArtVisualStudies behavioralNeuroscience biochemistry bioengineering biology biomedicalPhysics businessAdministration businessAdministration:Accounting businessAdministration:AccountingAndAdvisoryServices businessAdministration:BrandManagement businessAdministration:BusinessAnalytics businessAdministration:CorporateInnovation businessAdministration:EntrepreneurialStartups businessAdministration:FamilyBusiness businessAdministration:Finance businessAdministration:Fintech businessAdministration:HealthcareManagementAndConsulting businessAdministration:Management businessAdministration:ManagementInformationSystems businessAdministration:Marketing businessAdministration:MarketingAnalytics businessAdministration:SocialInnovationAndEntrepreneurship businessAdministration:SupplyChainManagement cellAndMolecularBiology chemicalEngineering chemistry civilEngineering communicationStudies computerEngineering computerScience computingAndLaw criminologyAndCriminalJustice culturalAnthropology cybersecurity dataScience design economics electricalEngineering english environmentalAndSustainabilityStudies environmentalEngineering environmentalScience environmentalStudies gameArtAndAnimation gameDesign globalAsianStudies healthScience history historyCultureAndLaw humanServices industrialEngineering internationalAffairs internationalBusiness internationalBusiness:Accounting internationalBusiness:AccountingAndAdvisoryServices internationalBusiness:BrandManagement internationalBusiness:BusinessAnalytics internationalBusiness:CorporateInnovation internationalBusiness:EntrepreneurialStartups internationalBusiness:FamilyBusiness internationalBusiness:Finance internationalBusiness:Fintech internationalBusiness:HealthcareManagementAndConsulting internationalBusiness:Management internationalBusiness:ManagementInformationSystems internationalBusiness:Marketing internationalBusiness:MarketingAnalytics internationalBusiness:SocialInnovationAndEntrepreneurship internationalBusiness:SupplyChainManagement journalism landscapeArchitecture linguistics marineBiology mathematics mechanicalEngineering mediaAndScreenStudies mediaArts music musicTechnology nursing pharmaceuticalSciences pharmacy(PharmD) philosophy physics politicalScience politicsPhilosophyEconomics psychology publicHealth publicRelations religiousStudies sociology spanish speechLanguagePathologyAndAudiology theatre"` - Major2 models.Major `json:"major2" validate:"not_equal_if_not_empty=Major0,not_equal_if_not_empty=Major1,omitempty,max=255,oneof=africanaStudies americanSignLanguage americanSignLanguage-EnglishInterpreting appliedPhysics architecturalStudies architecture art:ArtVisualStudies behavioralNeuroscience biochemistry bioengineering biology biomedicalPhysics businessAdministration businessAdministration:Accounting businessAdministration:AccountingAndAdvisoryServices businessAdministration:BrandManagement businessAdministration:BusinessAnalytics businessAdministration:CorporateInnovation businessAdministration:EntrepreneurialStartups businessAdministration:FamilyBusiness businessAdministration:Finance businessAdministration:Fintech businessAdministration:HealthcareManagementAndConsulting businessAdministration:Management businessAdministration:ManagementInformationSystems businessAdministration:Marketing businessAdministration:MarketingAnalytics businessAdministration:SocialInnovationAndEntrepreneurship businessAdministration:SupplyChainManagement cellAndMolecularBiology chemicalEngineering chemistry civilEngineering communicationStudies computerEngineering computerScience computingAndLaw criminologyAndCriminalJustice culturalAnthropology cybersecurity dataScience design economics electricalEngineering english environmentalAndSustainabilityStudies environmentalEngineering environmentalScience environmentalStudies gameArtAndAnimation gameDesign globalAsianStudies healthScience history historyCultureAndLaw humanServices industrialEngineering internationalAffairs internationalBusiness internationalBusiness:Accounting internationalBusiness:AccountingAndAdvisoryServices internationalBusiness:BrandManagement internationalBusiness:BusinessAnalytics internationalBusiness:CorporateInnovation internationalBusiness:EntrepreneurialStartups internationalBusiness:FamilyBusiness internationalBusiness:Finance internationalBusiness:Fintech internationalBusiness:HealthcareManagementAndConsulting internationalBusiness:Management internationalBusiness:ManagementInformationSystems internationalBusiness:Marketing internationalBusiness:MarketingAnalytics internationalBusiness:SocialInnovationAndEntrepreneurship internationalBusiness:SupplyChainManagement journalism landscapeArchitecture linguistics marineBiology mathematics mechanicalEngineering mediaAndScreenStudies mediaArts music musicTechnology nursing pharmaceuticalSciences pharmacy(PharmD) philosophy physics politicalScience politicsPhilosophyEconomics psychology publicHealth publicRelations religiousStudies sociology spanish speechLanguagePathologyAndAudiology theatre"` - College models.College `json:"college" validate:"omitempty,oneof=CAMD DMSB KCCS CE BCHS SL CPS CS CSSH"` - GraduationCycle models.GraduationCycle `json:"graduation_cycle" validate:"omitempty,max=255,oneof=december may"` - GraduationYear int16 `json:"graduation_year" validate:"omitempty"` -} - type UpdateUserRequestBody struct { - FirstName string `json:"first_name" validate:"omitempty,max=255"` - LastName string `json:"last_name" validate:"omitempty,max=255"` + Name string `json:"name" validate:"omitempty,max=255"` Major0 models.Major `json:"major0" validate:"not_equal_if_not_empty=Major1,not_equal_if_not_empty=Major2,omitempty,max=255,oneof=africanaStudies americanSignLanguage americanSignLanguage-EnglishInterpreting appliedPhysics architecturalStudies architecture art:ArtVisualStudies behavioralNeuroscience biochemistry bioengineering biology biomedicalPhysics businessAdministration businessAdministration:Accounting businessAdministration:AccountingAndAdvisoryServices businessAdministration:BrandManagement businessAdministration:BusinessAnalytics businessAdministration:CorporateInnovation businessAdministration:EntrepreneurialStartups businessAdministration:FamilyBusiness businessAdministration:Finance businessAdministration:Fintech businessAdministration:HealthcareManagementAndConsulting businessAdministration:Management businessAdministration:ManagementInformationSystems businessAdministration:Marketing businessAdministration:MarketingAnalytics businessAdministration:SocialInnovationAndEntrepreneurship businessAdministration:SupplyChainManagement cellAndMolecularBiology chemicalEngineering chemistry civilEngineering communicationStudies computerEngineering computerScience computingAndLaw criminologyAndCriminalJustice culturalAnthropology cybersecurity dataScience design economics electricalEngineering english environmentalAndSustainabilityStudies environmentalEngineering environmentalScience environmentalStudies gameArtAndAnimation gameDesign globalAsianStudies healthScience history historyCultureAndLaw humanServices industrialEngineering internationalAffairs internationalBusiness internationalBusiness:Accounting internationalBusiness:AccountingAndAdvisoryServices internationalBusiness:BrandManagement internationalBusiness:BusinessAnalytics internationalBusiness:CorporateInnovation internationalBusiness:EntrepreneurialStartups internationalBusiness:FamilyBusiness internationalBusiness:Finance internationalBusiness:Fintech internationalBusiness:HealthcareManagementAndConsulting internationalBusiness:Management internationalBusiness:ManagementInformationSystems internationalBusiness:Marketing internationalBusiness:MarketingAnalytics internationalBusiness:SocialInnovationAndEntrepreneurship internationalBusiness:SupplyChainManagement journalism landscapeArchitecture linguistics marineBiology mathematics mechanicalEngineering mediaAndScreenStudies mediaArts music musicTechnology nursing pharmaceuticalSciences pharmacy(PharmD) philosophy physics politicalScience politicsPhilosophyEconomics psychology publicHealth publicRelations religiousStudies sociology spanish speechLanguagePathologyAndAudiology theatre"` Major1 models.Major `json:"major1" validate:"not_equal_if_not_empty=Major0,not_equal_if_not_empty=Major2,omitempty,max=255,oneof=africanaStudies americanSignLanguage americanSignLanguage-EnglishInterpreting appliedPhysics architecturalStudies architecture art:ArtVisualStudies behavioralNeuroscience biochemistry bioengineering biology biomedicalPhysics businessAdministration businessAdministration:Accounting businessAdministration:AccountingAndAdvisoryServices businessAdministration:BrandManagement businessAdministration:BusinessAnalytics businessAdministration:CorporateInnovation businessAdministration:EntrepreneurialStartups businessAdministration:FamilyBusiness businessAdministration:Finance businessAdministration:Fintech businessAdministration:HealthcareManagementAndConsulting businessAdministration:Management businessAdministration:ManagementInformationSystems businessAdministration:Marketing businessAdministration:MarketingAnalytics businessAdministration:SocialInnovationAndEntrepreneurship businessAdministration:SupplyChainManagement cellAndMolecularBiology chemicalEngineering chemistry civilEngineering communicationStudies computerEngineering computerScience computingAndLaw criminologyAndCriminalJustice culturalAnthropology cybersecurity dataScience design economics electricalEngineering english environmentalAndSustainabilityStudies environmentalEngineering environmentalScience environmentalStudies gameArtAndAnimation gameDesign globalAsianStudies healthScience history historyCultureAndLaw humanServices industrialEngineering internationalAffairs internationalBusiness internationalBusiness:Accounting internationalBusiness:AccountingAndAdvisoryServices internationalBusiness:BrandManagement internationalBusiness:BusinessAnalytics internationalBusiness:CorporateInnovation internationalBusiness:EntrepreneurialStartups internationalBusiness:FamilyBusiness internationalBusiness:Finance internationalBusiness:Fintech internationalBusiness:HealthcareManagementAndConsulting internationalBusiness:Management internationalBusiness:ManagementInformationSystems internationalBusiness:Marketing internationalBusiness:MarketingAnalytics internationalBusiness:SocialInnovationAndEntrepreneurship internationalBusiness:SupplyChainManagement journalism landscapeArchitecture linguistics marineBiology mathematics mechanicalEngineering mediaAndScreenStudies mediaArts music musicTechnology nursing pharmaceuticalSciences pharmacy(PharmD) philosophy physics politicalScience politicsPhilosophyEconomics psychology publicHealth publicRelations religiousStudies sociology spanish speechLanguagePathologyAndAudiology theatre"` Major2 models.Major `json:"major2" validate:"not_equal_if_not_empty=Major0,not_equal_if_not_empty=Major1,omitempty,max=255,oneof=africanaStudies americanSignLanguage americanSignLanguage-EnglishInterpreting appliedPhysics architecturalStudies architecture art:ArtVisualStudies behavioralNeuroscience biochemistry bioengineering biology biomedicalPhysics businessAdministration businessAdministration:Accounting businessAdministration:AccountingAndAdvisoryServices businessAdministration:BrandManagement businessAdministration:BusinessAnalytics businessAdministration:CorporateInnovation businessAdministration:EntrepreneurialStartups businessAdministration:FamilyBusiness businessAdministration:Finance businessAdministration:Fintech businessAdministration:HealthcareManagementAndConsulting businessAdministration:Management businessAdministration:ManagementInformationSystems businessAdministration:Marketing businessAdministration:MarketingAnalytics businessAdministration:SocialInnovationAndEntrepreneurship businessAdministration:SupplyChainManagement cellAndMolecularBiology chemicalEngineering chemistry civilEngineering communicationStudies computerEngineering computerScience computingAndLaw criminologyAndCriminalJustice culturalAnthropology cybersecurity dataScience design economics electricalEngineering english environmentalAndSustainabilityStudies environmentalEngineering environmentalScience environmentalStudies gameArtAndAnimation gameDesign globalAsianStudies healthScience history historyCultureAndLaw humanServices industrialEngineering internationalAffairs internationalBusiness internationalBusiness:Accounting internationalBusiness:AccountingAndAdvisoryServices internationalBusiness:BrandManagement internationalBusiness:BusinessAnalytics internationalBusiness:CorporateInnovation internationalBusiness:EntrepreneurialStartups internationalBusiness:FamilyBusiness internationalBusiness:Finance internationalBusiness:Fintech internationalBusiness:HealthcareManagementAndConsulting internationalBusiness:Management internationalBusiness:ManagementInformationSystems internationalBusiness:Marketing internationalBusiness:MarketingAnalytics internationalBusiness:SocialInnovationAndEntrepreneurship internationalBusiness:SupplyChainManagement journalism landscapeArchitecture linguistics marineBiology mathematics mechanicalEngineering mediaAndScreenStudies mediaArts music musicTechnology nursing pharmaceuticalSciences pharmacy(PharmD) philosophy physics politicalScience politicsPhilosophyEconomics psychology publicHealth publicRelations religiousStudies sociology spanish speechLanguagePathologyAndAudiology theatre"` diff --git a/backend/entities/users/base/routes.go b/backend/entities/users/base/routes.go index 898a981ba..d6c7d8217 100644 --- a/backend/entities/users/base/routes.go +++ b/backend/entities/users/base/routes.go @@ -1,10 +1,10 @@ package base import ( - p "github.com/GenerateNU/sac/backend/auth" "github.com/GenerateNU/sac/backend/entities/users/followers" "github.com/GenerateNU/sac/backend/entities/users/members" "github.com/GenerateNU/sac/backend/entities/users/tags" + "github.com/GenerateNU/sac/backend/permission" "github.com/GenerateNU/sac/backend/types" "github.com/gofiber/fiber/v2" @@ -26,15 +26,13 @@ func UsersRouter(userParams types.RouteParams) fiber.Router { // api/v1/users/* users := userParams.Router.Group("/users") - users.Get("/", userParams.AuthMiddleware.Authorize(p.ReadAll), userParams.UtilityMiddleware.Paginator, userController.GetUsers) - users.Get("/me", userParams.AuthMiddleware.Authorize(p.UserRead), userController.GetMe) + users.Get("/", userParams.AuthMiddleware.Authorize(permission.ReadAll), userParams.UtilityMiddleware.Paginator, userController.GetUsers) // api/v1/users/:userID/* usersID := users.Group("/:userID") usersID.Get("/", userController.GetUser) usersID.Patch("/", userParams.AuthMiddleware.UserAuthorizeById, userController.UpdateUser) - usersID.Patch("/password", userParams.AuthMiddleware.UserAuthorizeById, userController.UpdatePassword) usersID.Delete("/", userParams.AuthMiddleware.UserAuthorizeById, userController.DeleteUser) return usersID diff --git a/backend/entities/users/base/service.go b/backend/entities/users/base/service.go index 272fe3612..4b9a93faa 100644 --- a/backend/entities/users/base/service.go +++ b/backend/entities/users/base/service.go @@ -1,12 +1,9 @@ package base import ( - "github.com/GenerateNU/sac/backend/auth" - authEntities "github.com/GenerateNU/sac/backend/entities/auth" "github.com/GenerateNU/sac/backend/entities/models" "github.com/GenerateNU/sac/backend/errs" "github.com/garrettladley/fiberpaginate" - "github.com/google/uuid" "github.com/GenerateNU/sac/backend/entities/users" "github.com/GenerateNU/sac/backend/types" @@ -15,10 +12,8 @@ import ( type UserServiceInterface interface { GetUsers(pageInfo fiberpaginate.PageInfo) ([]models.User, error) - GetMe(id uuid.UUID) (*models.User, error) GetUser(id string) (*models.User, error) UpdateUser(id string, userBody UpdateUserRequestBody) (*models.User, error) - UpdatePassword(id string, passwordBody authEntities.UpdatePasswordRequestBody) error DeleteUser(id string) error } @@ -34,10 +29,6 @@ func (u *UserService) GetUsers(pageInfo fiberpaginate.PageInfo) ([]models.User, return GetUsers(u.DB, pageInfo) } -func (u *UserService) GetMe(id uuid.UUID) (*models.User, error) { - return users.GetUser(u.DB, id) -} - func (u *UserService) GetUser(id string) (*models.User, error) { idAsUUID, err := utilities.ValidateID(id) if err != nil { @@ -69,36 +60,6 @@ func (u *UserService) UpdateUser(id string, userBody UpdateUserRequestBody) (*mo return UpdateUser(u.DB, *idAsUUID, *user) } -func (u *UserService) UpdatePassword(id string, passwordBody authEntities.UpdatePasswordRequestBody) error { - idAsUUID, err := utilities.ValidateID(id) - if err != nil { - return err - } - - if err := utilities.Validate(u.Validate, passwordBody, - *utilities.NewMaybeError("old_password", auth.ValidatePassword(passwordBody.OldPassword)), - *utilities.NewMaybeError("new_password", auth.ValidatePassword(passwordBody.NewPassword))); err != nil { - return err - } - - passwordHash, err := GetUserPasswordHash(u.DB, *idAsUUID) - if err != nil { - return err - } - - correct, passwordErr := auth.CompareHash(passwordBody.OldPassword, *passwordHash) - if passwordErr != nil || !correct { - return err - } - - hash, hashErr := auth.ComputeHash(passwordBody.NewPassword) - if hashErr != nil { - return err - } - - return users.UpdatePassword(u.DB, *idAsUUID, *hash) -} - func (u *UserService) DeleteUser(id string) error { idAsUUID, err := utilities.ValidateID(id) if err != nil { diff --git a/backend/entities/users/base/transactions.go b/backend/entities/users/base/transactions.go index e3acaf1bb..973bb063a 100644 --- a/backend/entities/users/base/transactions.go +++ b/backend/entities/users/base/transactions.go @@ -15,25 +15,13 @@ import ( func GetUsers(db *gorm.DB, pageInfo fiberpaginate.PageInfo) ([]models.User, error) { var users []models.User - if err := db.Omit("password_hash").Scopes(utilities.IntoScope(pageInfo, db)).Find(&users).Error; err != nil { + if err := db.Scopes(utilities.IntoScope(pageInfo, db)).Find(&users).Error; err != nil { return nil, err } return users, nil } -func GetUserPasswordHash(db *gorm.DB, id uuid.UUID) (*string, error) { - var user models.User - if err := db.Select("password_hash").First(&user, id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, utilities.ErrNotFound - } - return nil, err - } - - return &user.PasswordHash, nil -} - func UpdateUser(db *gorm.DB, id uuid.UUID, user models.User) (*models.User, error) { var existingUser models.User diff --git a/backend/entities/users/transactions.go b/backend/entities/users/transactions.go index 38b093cc1..defa68007 100644 --- a/backend/entities/users/transactions.go +++ b/backend/entities/users/transactions.go @@ -1,6 +1,7 @@ package users import ( + "context" "errors" "github.com/GenerateNU/sac/backend/entities/models" @@ -19,7 +20,7 @@ func GetUser(db *gorm.DB, id uuid.UUID, preloads ...transactions.OptionalQuery) query = preload(query) } - if err := query.Omit("password_hash").First(&user, id).Error; err != nil { + if err := query.First(&user, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, utilities.ErrNotFound } @@ -29,10 +30,9 @@ func GetUser(db *gorm.DB, id uuid.UUID, preloads ...transactions.OptionalQuery) return &user, nil } -func GetUserByEmail(db *gorm.DB, email string) (*models.User, error) { +func GetUserByEmail(ctx context.Context, db *gorm.DB, email string) (*models.User, error) { var user models.User - - if err := db.Where("email = ?", email).First(&user).Error; err != nil { + if err := db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, utilities.ErrNotFound } @@ -41,25 +41,3 @@ func GetUserByEmail(db *gorm.DB, email string) (*models.User, error) { return &user, nil } - -func UpdateEmailVerification(db *gorm.DB, id uuid.UUID) error { - result := db.Model(&models.User{}).Where("id = ?", id).Update("is_verified", true) - if result.RowsAffected == 0 { - if result.Error == nil { - return utilities.ErrNotFound - } - return result.Error - } - return nil -} - -func UpdatePassword(db *gorm.DB, id uuid.UUID, passwordHash string) error { - result := db.Model(&models.User{}).Where("id = ?", id).Update("password_hash", passwordHash) - if result.RowsAffected == 0 { - if result.Error == nil { - return utilities.ErrNotFound - } - return result.Error - } - return nil -} diff --git a/backend/go.mod b/backend/go.mod index 35e4f238c..27e8c9175 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,7 +4,6 @@ go 1.22.2 require ( github.com/a-h/templ v0.2.707 - github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 github.com/aws/aws-sdk-go v1.53.10 github.com/garrettladley/fiberpaginate v1.0.5 github.com/garrettladley/mattress v0.4.0 @@ -12,7 +11,6 @@ require ( github.com/goccy/go-json v0.10.3 github.com/gofiber/fiber/v2 v2.52.4 github.com/gofiber/swagger v1.0.0 - github.com/golang-jwt/jwt v3.2.2+incompatible github.com/google/uuid v1.6.0 github.com/huandu/go-assert v1.1.6 github.com/joho/godotenv v1.5.1 @@ -22,7 +20,7 @@ require ( github.com/swaggo/swag v1.16.3 go.opentelemetry.io/otel/sdk v1.27.0 go.opentelemetry.io/otel/trace v1.27.0 - golang.org/x/crypto v0.23.0 + golang.org/x/oauth2 v0.17.0 golang.org/x/text v0.15.0 gorm.io/driver/postgres v1.5.7 gorm.io/gorm v1.25.10 @@ -33,24 +31,30 @@ require ( github.com/awnumar/memguard v0.22.5 // 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.9 // indirect golang.org/x/sync v0.7.0 // indirect ) require ( + cloud.google.com/go/compute v1.23.3 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect go.opentelemetry.io/contrib v1.17.0 // indirect go.opentelemetry.io/otel/metric v1.27.0 // indirect go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.33.0 // indirect ) require ( @@ -77,11 +81,11 @@ require ( github.com/klauspost/compress v1.17.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/markbates/going v1.0.3 github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/swaggo/files/v2 v2.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.52.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index a4152969c..42e8d873a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,7 @@ +cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= +cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -6,8 +10,6 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U= github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8= -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.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/awnumar/memcall v0.2.0 h1:sRaogqExTOOkkNwO9pzJsL8jrOV29UuUW7teRMfbqtI= @@ -40,6 +42,7 @@ github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKoh github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/garrettladley/fiberpaginate v1.0.5 h1:H1Kj7UQBhaBxbCcDeSY3wlW9Hutq/hJ8NSEM86T9vAs= @@ -79,21 +82,24 @@ github.com/gofiber/swagger v1.0.0 h1:BzUzDS9ZT6fDUa692kxmfOjc1DZiloLiPK/W5z1H1tc github.com/gofiber/swagger v1.0.0/go.mod h1:QrYNF1Yrc7ggGK6ATsJ6yfH/8Zi5bu9lA7wB8TmCecg= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -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/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/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/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -116,8 +122,6 @@ 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.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -130,6 +134,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/markbates/going v1.0.3 h1:mY45T5TvW+Xz5A6jY7lf4+NLg9D8+iuStIHyR7M8qsE= +github.com/markbates/going v1.0.3/go.mod h1:fQiT6v6yQar9UD6bd/D4Z5Afbk9J6BBVBtLiyY4gp2o= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -143,6 +149,9 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= @@ -163,10 +172,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -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/serenize/snaker v0.0.0-20171204205717-a683aaf2d516/go.mod h1:Yow6lPLSAXx2ifx470yD/nUe22Dv5vBvxK/UK9UUTVs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -184,6 +190,7 @@ github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7g github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/contrib v1.17.0 h1:lJJdtuNsP++XHD7tXDYEFSpsqIc7DzShuXMR5PwkmzA= go.opentelemetry.io/contrib v1.17.0/go.mod h1:gIzjwWFoGazJmtCaDgViqOSJPde2mCWzv60o0bWPcZs= go.opentelemetry.io/contrib/propagators/b3 v1.17.0 h1:ImOVvHnku8jijXqkwCSyYKRDt2YrnGXD4BbhcpfbfJo= @@ -204,27 +211,65 @@ go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5 go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= +golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.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/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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +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= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 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= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/backend/integrations/email/email.go b/backend/integrations/email/email.go index b4bcc8601..704735ffe 100644 --- a/backend/integrations/email/email.go +++ b/backend/integrations/email/email.go @@ -11,38 +11,36 @@ import ( "github.com/a-h/templ" - "github.com/afex/hystrix-go/hystrix" "github.com/resend/resend-go/v2" ) -type EmailClientInterface interface { - SendPasswordResetEmail(name string, email string, token string) error - SendEmailVerification(email string, code string) error - SendWelcomeEmail(name string, email string) error - SendPasswordChangedEmail(name string, email string) error +type Emailer interface { + SendWelcome(ctx context.Context, email string, name string) error } type ResendClient struct { - Client *resend.Client - Dev bool + client *resend.Client + dev bool } -func NewResendClient(settings config.ResendSettings, dev bool) EmailClientInterface { - hystrix.ConfigureCommand("send-email", hystrix.CommandConfig{ - Timeout: 5000, - MaxConcurrentRequests: 100, - ErrorPercentThreshold: 25, - }) - +func NewResendClient(settings config.ResendSettings, dev bool) Emailer { return &ResendClient{ - Client: resend.NewClient(settings.APIKey.Expose()), - Dev: dev, + client: resend.NewClient(settings.APIKey.Expose()), + dev: dev, } } -func send(fromEmail string, toEmail string, subject string, template templ.Component, client *resend.Client) error { +func (r *ResendClient) SendWelcome(ctx context.Context, name string, email string) error { + if r.dev { + return nil + } + + return r.send(ctx, constants.ONBOARDING_EMAIL, email, "Welcome to Hippo", emails.Welcome(name)) +} + +func (r *ResendClient) send(ctx context.Context, fromEmail string, toEmail string, subject string, template templ.Component) error { buffer := new(bytes.Buffer) - err := template.Render(context.Background(), buffer) + err := template.Render(ctx, buffer) if err != nil { return fmt.Errorf("failed to render email template: %w", err) } @@ -54,45 +52,9 @@ func send(fromEmail string, toEmail string, subject string, template templ.Compo Html: buffer.String(), } - err = hystrix.Do("send-email", func() error { - _, err := client.Emails.Send(params) - return err - }, nil) - if err != nil { + if _, err := r.client.Emails.Send(params); err != nil { return fmt.Errorf("failed to send email: %w", err) } return nil } - -func (r *ResendClient) SendPasswordResetEmail(name string, email string, token string) error { - if r.Dev { - return nil - } - - return send(constants.DEFAULT_FROM_EMAIL, email, "Password Reset", emails.PasswordReset(name, fmt.Sprintf("https://hipponeu.com/reset/%s", token)), r.Client) -} - -func (r *ResendClient) SendEmailVerification(email string, code string) error { - if r.Dev { - return nil - } - - return send(constants.DEFAULT_FROM_EMAIL, email, "Email Verification", emails.Verification(code), r.Client) -} - -func (r *ResendClient) SendWelcomeEmail(name string, email string) error { - if r.Dev { - return nil - } - - return send(constants.ONBOARDING_EMAIL, email, "Welcome to Hippo", emails.Welcome(name), r.Client) -} - -func (r *ResendClient) SendPasswordChangedEmail(name string, email string) error { - if r.Dev { - return nil - } - - return send(constants.DEFAULT_FROM_EMAIL, email, "Password Changed", emails.PasswordChangeComplete(name), r.Client) -} diff --git a/backend/integrations/expose.go b/backend/integrations/expose.go index f7d138f98..be22f05eb 100644 --- a/backend/integrations/expose.go +++ b/backend/integrations/expose.go @@ -3,11 +3,9 @@ package integrations import ( "github.com/GenerateNU/sac/backend/integrations/email" "github.com/GenerateNU/sac/backend/integrations/file" - "github.com/GenerateNU/sac/backend/integrations/oauth" ) type Integrations struct { - Email email.EmailClientInterface + Email email.Emailer File file.FileClientInterface - OAuth oauth.OauthProviderSettings } diff --git a/backend/integrations/oauth/README.md b/backend/integrations/oauth/README.md new file mode 100644 index 000000000..99a15559d --- /dev/null +++ b/backend/integrations/oauth/README.md @@ -0,0 +1,3 @@ +# OAuth Acknowledgement + +## Forked code from @markbates' [goth package](https://github.com/markbates/goth/tree/v1.80.0) and @Shareed2k's [goth_fiber package](https://github.com/Shareed2k/goth_fiber/tree/master) to fit into our internal structure diff --git a/backend/integrations/oauth/google.go b/backend/integrations/oauth/google.go deleted file mode 100644 index 55dff4aa5..000000000 --- a/backend/integrations/oauth/google.go +++ /dev/null @@ -1,47 +0,0 @@ -package oauth - -import ( - "errors" - "fmt" - "net/http" - "net/url" - - "github.com/GenerateNU/sac/backend/config" - "github.com/GenerateNU/sac/backend/utilities" -) - -type GoogleOAuthClient struct { - OAuthConfig config.OAuthSettings -} - -func (client *GoogleOAuthClient) AuthorizeURL(state string) string { - values := url.Values{} - values.Set("client_id", client.OAuthConfig.ClientID.Expose()) - values.Set("redirect_uri", client.OAuthConfig.RedirectURI) - values.Set("response_type", client.OAuthConfig.ResponseType) - values.Set("scope", client.OAuthConfig.Scopes) - values.Set("access_type", client.OAuthConfig.AccessType) - values.Set("include_granted_scopes", client.OAuthConfig.IncludeGrantedScopes) - values.Set("prompt", client.OAuthConfig.Prompt) - values.Set("state", state) - - return fmt.Sprintf("%s/auth?%s", client.OAuthConfig.BaseURL, values.Encode()) -} - -func (client *GoogleOAuthClient) GetConfig() config.OAuthSettings { - return client.OAuthConfig -} - -func (client *GoogleOAuthClient) Revoke(token string) error { - resp, err := utilities.Request(http.MethodPost, fmt.Sprintf("%s/revoke?token=%s", client.OAuthConfig.TokenURL, token), nil, utilities.FormURLEncoded()) - if err != nil { - return err - } - defer resp.Body.Close() - - if !utilities.IsOk(resp.StatusCode) { - return errors.New("failed to revoke token") - } - - return nil -} diff --git a/backend/integrations/oauth/oauth.go b/backend/integrations/oauth/oauth.go deleted file mode 100644 index 7416d6190..000000000 --- a/backend/integrations/oauth/oauth.go +++ /dev/null @@ -1,18 +0,0 @@ -package oauth - -import "github.com/GenerateNU/sac/backend/config" - -type OAuthResourceClientInterface interface { - AuthorizeURL(state string) string - GetConfig() config.OAuthSettings - Revoke(token string) error -} - -type OAuthClient struct { - ResourceClient OAuthResourceClientInterface -} - -type OauthProviderSettings struct { - Outlook OutlookOAuthClient - Google GoogleOAuthClient -} diff --git a/backend/integrations/oauth/outlook.go b/backend/integrations/oauth/outlook.go deleted file mode 100644 index df6bc2cb0..000000000 --- a/backend/integrations/oauth/outlook.go +++ /dev/null @@ -1,34 +0,0 @@ -package oauth - -import ( - "fmt" - "net/url" - - "github.com/GenerateNU/sac/backend/config" -) - -type OutlookOAuthClient struct { - OAuthConfig config.OAuthSettings -} - -func (client *OutlookOAuthClient) AuthorizeURL(state string) string { - values := url.Values{} - values.Set("client_id", client.OAuthConfig.ClientID.Expose()) - values.Set("redirect_uri", client.OAuthConfig.RedirectURI) - values.Set("response_type", client.OAuthConfig.ResponseType) - values.Set("response_mode", client.OAuthConfig.ResponseMode) - values.Set("scope", client.OAuthConfig.Scopes) - values.Set("access_type", client.OAuthConfig.AccessType) - values.Set("state", state) - - return fmt.Sprintf("%s/authorize?%s", client.OAuthConfig.BaseURL, values.Encode()) -} - -func (client *OutlookOAuthClient) GetConfig() config.OAuthSettings { - return client.OAuthConfig -} - -func (client *OutlookOAuthClient) Revoke(token string) error { - // Outlook does not expose a revokation endpoint :/ - return nil -} diff --git a/backend/integrations/oauth/soth/goog/goog.go b/backend/integrations/oauth/soth/goog/goog.go new file mode 100644 index 000000000..d62e46352 --- /dev/null +++ b/backend/integrations/oauth/soth/goog/goog.go @@ -0,0 +1,216 @@ +package goog + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" + go_json "github.com/goccy/go-json" + + "github.com/GenerateNU/sac/backend/utilities" + + "golang.org/x/oauth2" + + m "github.com/garrettladley/mattress" + goog "golang.org/x/oauth2/google" +) + +const ( + endpointProfile string = "https://www.googleapis.com/oauth2/v2/userinfo" +) + +var ( + endpoint oauth2.Endpoint = goog.Endpoint + defaultScopes []string = []string{"email", "https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.readonly"} +) + +// New creates a new Google provider, and sets up important connection details. +// You should always call `google.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey *m.Secret[string], secret *m.Secret[string], callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "google", + + // We can get a refresh token from Google by this option. + // See https://developers.google.com/identity/protocols/oauth2/openid-connect#access-type-param + authCodeOptions: []oauth2.AuthCodeOption{ + oauth2.AccessTypeOffline, + }, + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `oauth.Provider` for accessing Google. +type Provider struct { + ClientKey *m.Secret[string] + Secret *m.Secret[string] + CallbackURL string + config *oauth2.Config + authCodeOptions []oauth2.AuthCodeOption + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Debug is a no-op for the goog package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Google for an authentication endpoint. +func (p *Provider) BeginAuth(state string) (soth.Session, error) { + url := p.config.AuthCodeURL(state, p.authCodeOptions...) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +type googleUser struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + FirstName string `json:"given_name"` + LastName string `json:"family_name"` + Link string `json:"link"` + Picture string `json:"picture"` +} + +// FetchUser will go to Google and access basic information about the user. +func (p *Provider) FetchUser(session soth.Session) (soth.User, error) { + sess := session.(*Session) + user := soth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + IDToken: sess.IDToken, + } + + if user.AccessToken == "" { + // Data is not yet retrieved, since accessToken is still empty. + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + resp, err := utilities.Request(http.MethodGet, fmt.Sprintf("%s?access_token=%s", endpointProfile, url.QueryEscape(sess.AccessToken)), nil) + if err != nil { + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + responseBytes, err := io.ReadAll(resp.Body) + if err != nil { + return user, err + } + + var u googleUser + if err := go_json.Unmarshal(responseBytes, &u); err != nil { + return user, err + } + + // Extract the user data we got from Google into our oauth.User. + user.Name = u.Name + user.FirstName = u.FirstName + user.LastName = u.LastName + user.NickName = u.Name + user.Email = u.Email + user.AvatarURL = u.Picture + user.UserID = u.ID + // Google provides other useful fields such as 'hd'; get them from RawData + if err := go_json.Unmarshal(responseBytes, &user.RawData); err != nil { + return user, err + } + + return user, nil +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey.Expose(), + ClientSecret: provider.Secret.Expose(), + RedirectURL: provider.CallbackURL, + Endpoint: endpoint, + Scopes: []string{}, + } + + if len(scopes) > 0 { + c.Scopes = append(c.Scopes, scopes...) + } else { + c.Scopes = append(c.Scopes, defaultScopes...) + } + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(soth.ContextForClient(utilities.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +// SetPrompt sets the prompt values for the google OAuth call. Use this to +// force users to choose and account every time by passing "select_account", +// for example. +// See https://developers.google.com/identity/protocols/OpenIDConnect#authenticationuriparameters +func (p *Provider) SetPrompt(prompt ...string) { + if len(prompt) == 0 { + return + } + p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("prompt", strings.Join(prompt, " "))) +} + +// SetHostedDomain sets the hd parameter for google OAuth call. +// Use this to force user to pick user from specific hosted domain. +// See https://developers.google.com/identity/protocols/oauth2/openid-connect#hd-param +func (p *Provider) SetHostedDomain(hd string) { + if hd == "" { + return + } + p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("hd", hd)) +} + +// SetLoginHint sets the login_hint parameter for the Google OAuth call. +// Use this to prompt the user to log in with a specific account. +// See https://developers.google.com/identity/protocols/oauth2/openid-connect#login-hint +func (p *Provider) SetLoginHint(loginHint string) { + if loginHint == "" { + return + } + p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("login_hint", loginHint)) +} + +// SetAccessType sets the access_type parameter for the Google OAuth call. +// If an access token is being requested, the client does not receive a refresh token unless a value of offline is specified. +// See https://developers.google.com/identity/protocols/oauth2/openid-connect#access-type-param +func (p *Provider) SetAccessType(at string) { + if at == "" { + return + } + p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("access_type", at)) +} diff --git a/backend/integrations/oauth/soth/goog/session.go b/backend/integrations/oauth/soth/goog/session.go new file mode 100644 index 000000000..7937f0ae8 --- /dev/null +++ b/backend/integrations/oauth/soth/goog/session.go @@ -0,0 +1,64 @@ +package goog + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" + "github.com/GenerateNU/sac/backend/utilities" +) + +// Session stores data during the auth process with Google. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + IDToken string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Google provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(soth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Google and return the access token to be stored for future use. +func (s *Session) Authorize(provider soth.Provider, params soth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(soth.ContextForClient(utilities.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + s.IDToken = token.Extra("id_token").(string) + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (soth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/backend/integrations/oauth/soth/msft/msft.go b/backend/integrations/oauth/soth/msft/msft.go new file mode 100644 index 000000000..d77321600 --- /dev/null +++ b/backend/integrations/oauth/soth/msft/msft.go @@ -0,0 +1,190 @@ +package msft + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + + go_json "github.com/goccy/go-json" + + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" + "github.com/GenerateNU/sac/backend/utilities" + + "github.com/markbates/going/defaults" + "golang.org/x/oauth2" + + m "github.com/garrettladley/mattress" +) + +const ( + // #nosec G101 + authURLFmt string = "https://login.microsoftonline.com/%s/oauth2/v2.0/authorize" + // #nosec G101 + tokenURLFmt string = "https://login.microsoftonline.com/%s/oauth2/v2.0/token" + endpointProfile string = "https://graph.microsoft.com/v1.0/me" +) + +var defaultScopes = []string{"openid", "offline_access", "user.read", "calendars.readwrite", "email", "profile"} + +// New creates a new microsoftonline Provider, and sets up important connection details. +// You should always call `msft.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey *m.Secret[string], secret *m.Secret[string], callbackURL string, tenant string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + ProviderName: "microsoftonline", + tenant: tenant, + } + + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `soth.Provider` for accessing microsoftonline. +type Provider struct { + ClientKey *m.Secret[string] + Secret *m.Secret[string] + CallbackURL string + config *oauth2.Config + ProviderName string + tenant string +} + +// Name is the name used to retrieve this Provider later. +func (p *Provider) Name() string { + return p.ProviderName +} + +// SetName is to update the name of the Provider (needed in case of multiple Providers of 1 type) +func (p *Provider) SetName(name string) { + p.ProviderName = name +} + +// Debug is a no-op for the msft package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks MicrosoftOnline for an authentication end-point. +func (p *Provider) BeginAuth(state string) (soth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to MicrosoftOnline and access basic information about the user. +func (p *Provider) FetchUser(session soth.Session) (soth.User, error) { + msSession := session.(*Session) + user := soth.User{ + AccessToken: msSession.AccessToken, + Provider: p.Name(), + ExpiresAt: msSession.ExpiresAt, + } + + if user.AccessToken == "" { + return user, fmt.Errorf("%s cannot get user information without accessToken", p.ProviderName) + } + + resp, err := utilities.Request(http.MethodGet, endpointProfile, nil, utilities.Authorization(msSession.AccessToken)) + if err != nil { + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.ProviderName, resp.StatusCode) + } + + user.AccessToken = msSession.AccessToken + + err = userFromReader(resp.Body, &user) + return user, err +} + +// RefreshTokenAvailable refresh token is provided by auth Provider or not +// available for microsoft online as session size hit the limit of max cookie size +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + if refreshToken == "" { + return nil, errors.New("no refresh token provided") + } + + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(soth.ContextForClient(utilities.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + var ( + authURL string + tokenURL string + ) + if provider.tenant == "" { + authURL = fmt.Sprintf(authURLFmt, "common") + tokenURL = fmt.Sprintf(tokenURLFmt, "common") + } else { + authURL = fmt.Sprintf(authURLFmt, provider.tenant) + tokenURL = fmt.Sprintf(tokenURLFmt, provider.tenant) + } + + c := &oauth2.Config{ + ClientID: provider.ClientKey.Expose(), + ClientSecret: provider.Secret.Expose(), + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + c.Scopes = append(c.Scopes, scopes...) + if len(scopes) == 0 { + c.Scopes = append(c.Scopes, defaultScopes...) + } + + return c +} + +func userFromReader(r io.Reader, user *soth.User) error { + buf := &bytes.Buffer{} + tee := io.TeeReader(r, buf) + + u := struct { + ID string `json:"id"` + Name string `json:"displayName"` + Email string `json:"mail"` + FirstName string `json:"givenName"` + LastName string `json:"surname"` + UserPrincipalName string `json:"userPrincipalName"` + }{} + + if err := go_json.NewDecoder(tee).Decode(&u); err != nil { + return err + } + + raw := map[string]interface{}{} + if err := go_json.NewDecoder(buf).Decode(&raw); err != nil { + return err + } + + user.UserID = u.ID + user.Email = defaults.String(u.Email, u.UserPrincipalName) + user.Name = u.Name + user.NickName = u.Name + user.FirstName = u.FirstName + user.LastName = u.LastName + user.RawData = raw + + return nil +} diff --git a/backend/integrations/oauth/soth/msft/session.go b/backend/integrations/oauth/soth/msft/session.go new file mode 100644 index 000000000..e271d4c4c --- /dev/null +++ b/backend/integrations/oauth/soth/msft/session.go @@ -0,0 +1,63 @@ +package msft + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" + "github.com/GenerateNU/sac/backend/utilities" +) + +// Session is the implementation of `soth.Session` for accessing microsoftonline. +// Refresh token not available for microsoft online: session size hit the limit of max cookie size +type Session struct { + AuthURL string + AccessToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Microsoft provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(soth.NoAuthUrlErrorMessage) + } + + return s.AuthURL, nil +} + +// Authorize the session with Microsoft and return the access token to be stored for future use. +func (s *Session) Authorize(provider soth.Provider, params soth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(soth.ContextForClient(utilities.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.ExpiresAt = token.Expiry + + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (soth.Session, error) { + session := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(session) + return session, err +} diff --git a/backend/integrations/oauth/soth/provider.go b/backend/integrations/oauth/soth/provider.go new file mode 100644 index 000000000..521ac8339 --- /dev/null +++ b/backend/integrations/oauth/soth/provider.go @@ -0,0 +1,67 @@ +package soth + +import ( + "context" + "fmt" + "net/http" + + "golang.org/x/oauth2" +) + +// Provider needs to be implemented for each 3rd party authentication provider +// e.g. Facebook, Twitter, etc... +type Provider interface { + Name() string + SetName(name string) + BeginAuth(state string) (Session, error) + UnmarshalSession(string) (Session, error) + FetchUser(Session) (User, error) + Debug(bool) + RefreshToken(refreshToken string) (*oauth2.Token, error) // Get new access token based on the refresh token + RefreshTokenAvailable() bool // Refresh token is provided by auth provider or not +} + +const NoAuthUrlErrorMessage = "an AuthURL has not been set" + +// Providers is list of known/available providers. +type Providers map[string]Provider + +var providers = Providers{} + +// UseProviders adds a list of available providers for use with Soth. +// Can be called multiple times. If you pass the same provider more +// than once, the last will be used. +func UseProviders(viders ...Provider) { + for _, provider := range viders { + providers[provider.Name()] = provider + } +} + +// GetProviders returns a list of all the providers currently in use. +func GetProviders() Providers { + return providers +} + +// GetProvider returns a previously created provider. If Soth has not +// been told to use the named provider it will return an error. +func GetProvider(name string) (Provider, error) { + provider := providers[name] + if provider == nil { + return nil, fmt.Errorf("no provider for %s exists", name) + } + return provider, nil +} + +// ClearProviders will remove all providers currently in use. +// This is useful, mostly, for testing purposes. +func ClearProviders() { + providers = Providers{} +} + +// ContextForClient provides a context for use with oauth2. +func ContextForClient(h *http.Client) context.Context { + if h == nil { + return context.Background() + } + return context.WithValue(context.Background(), oauth2.HTTPClient, h) +} diff --git a/backend/integrations/oauth/soth/session.go b/backend/integrations/oauth/soth/session.go new file mode 100644 index 000000000..79115a8a5 --- /dev/null +++ b/backend/integrations/oauth/soth/session.go @@ -0,0 +1,21 @@ +package soth + +// Params is used to pass data to sessions for authorization. An existing +// implementation, and the one most likely to be used, is `url.Values`. +type Params interface { + Get(string) string +} + +// Session needs to be implemented as part of the provider package. +// It will be marshaled and persisted between requests to "tie" +// the start and the end of the authorization process with a +// 3rd party provider. +type Session interface { + // GetAuthURL returns the URL for the authentication end-point for the provider. + GetAuthURL() (string, error) + // Marshal generates a string representation of the Session for storing between requests. + Marshal() string + // Authorize should validate the data from the provider and return an access token + // that can be stored for later access to the provider. + Authorize(Provider, Params) (string, error) +} diff --git a/backend/integrations/oauth/soth/sothic/params.go b/backend/integrations/oauth/soth/sothic/params.go new file mode 100644 index 000000000..850975a15 --- /dev/null +++ b/backend/integrations/oauth/soth/sothic/params.go @@ -0,0 +1,11 @@ +package sothic + +import "github.com/gofiber/fiber/v2" + +type Params struct { + c *fiber.Ctx +} + +func (p *Params) Get(key string) string { + return p.c.Query(key) +} diff --git a/backend/integrations/oauth/soth/sothic/sothic.go b/backend/integrations/oauth/soth/sothic/sothic.go new file mode 100644 index 000000000..5cec4bd45 --- /dev/null +++ b/backend/integrations/oauth/soth/sothic/sothic.go @@ -0,0 +1,419 @@ +package sothic + +import ( + "bytes" + "compress/gzip" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "net/url" + "path/filepath" + "strings" + + "github.com/GenerateNU/sac/backend/database/store" + "github.com/caarlos0/env/v11" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" + "github.com/joho/godotenv" + + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" + m "github.com/garrettladley/mattress" +) + +type key int + +const ( + SessionName string = "_sothic_session" + + // ProviderParamKey can be used as a key in context when passing in a provider + ProviderParamKey key = iota +) + +// Session can/should be set by applications using gothic. The default is a cookie store. +var ( + SessionStore *session.Store +) + +type storageSettings struct { + username string + password *m.Secret[string] + host string + port uint + db int + // TLSConfig *TLSConfig +} + +func (s *storageSettings) Username() string { + return s.username +} + +func (s *storageSettings) Password() *m.Secret[string] { + return s.password +} + +func (s *storageSettings) Host() string { + return s.host +} + +func (s *storageSettings) Port() uint { + return s.port +} + +func (s *storageSettings) DB() int { + return s.db +} + +type intermediateStorageSettings struct { + Username string `env:"SAC_REDIS_SESSION_USERNAME"` + Password string `env:"SAC_REDIS_SESSION_PASSWORD"` + Host string `env:"SAC_REDIS_SESSION_HOST"` + Port uint `env:"SAC_REDIS_SESSION_PORT"` + DB int `env:"SAC_REDIS_SESSION_DB"` + // TLSConfig *intermediateTLSConfig `env:"TLS_CONFIG"` +} + +func (i *intermediateStorageSettings) into() (*storageSettings, error) { + password, err := m.NewSecret(i.Password) + if err != nil { + return nil, fmt.Errorf("failed to create secret from password: %s", err.Error()) + } + + return &storageSettings{ + username: i.Username, + password: password, + host: i.Host, + port: i.Port, + db: i.DB, + // TLSConfig: i.TLSConfig.into(), + }, nil +} + +func init() { + if err := godotenv.Load(filepath.Join("..", "config", ".env.dev")); err != nil { + panic(fmt.Errorf("failed to load environment variables: %s", err.Error())) + } + + intSettings, err := env.ParseAs[intermediateStorageSettings]() + if err != nil { + panic(fmt.Errorf("failed to parse environment variables: %s", err.Error())) + } + + settings, err := intSettings.into() + if err != nil { + panic(fmt.Errorf("failed to convert environment variables: %s", err.Error())) + } + + // optional config + config := session.Config{ + Storage: store.NewRedisClient(settings), + KeyLookup: fmt.Sprintf("cookie:%s", SessionName), + // for local + CookieHTTPOnly: true, + // MARK: secure in prod + // TODO: use build tags to set this + } + + SessionStore = session.New(config) +} + +/* +BeginAuthHandler is a convenience handler for starting the authentication process. +It expects to be able to get the name of the provider from the query parameters +as either "provider" or ":provider". + +BeginAuthHandler will redirect the user to the appropriate authentication end-point +for the requested provider. + +See https://github.com/markbates/goth/examples/main.go to see this in action. +*/ +func BeginAuthHandler(c *fiber.Ctx) error { + url, err := GetAuthURL(c) + if err != nil { + return c.Status(fiber.StatusBadRequest).SendString(err.Error()) + } + + return c.Redirect(url, fiber.StatusTemporaryRedirect) +} + +// SetState sets the state string associated with the given request. +// If no state string is associated with the request, one will be generated. +// This state is sent to the provider and can be retrieved during the +// callback. +func SetState(c *fiber.Ctx) string { + state := c.Query("state") + if len(state) > 0 { + return state + } + + // If a state query param is not passed in, generate a random + // base64-encoded nonce so that the state on the auth URL + // is unguessable, preventing CSRF attacks, as described in + // + // https://auth0.com/docs/protocols/oauth2/oauth-state#keep-reading + nonceBytes := make([]byte, 64) + _, err := io.ReadFull(rand.Reader, nonceBytes) + if err != nil { + panic("gothic: source of randomness unavailable: " + err.Error()) + } + return base64.URLEncoding.EncodeToString(nonceBytes) +} + +// GetState gets the state returned by the provider during the callback. +// This is used to prevent CSRF attacks, see +// http://tools.ietf.org/html/rfc6749#section-10.12 +func GetState(c *fiber.Ctx) string { + return c.Query("state") +} + +/* +GetAuthURL starts the authentication process with the requested provided. +It will return a URL that should be used to send users to. + +It expects to be able to get the name of the provider from the query parameters +as either "provider" or ":provider". + +I would recommend using the BeginAuthHandler instead of doing all of these steps +yourself, but that's entirely up to you. +*/ +func GetAuthURL(c *fiber.Ctx) (string, error) { + if SessionStore == nil { + return "", errors.New("session store unexpectedly nil") + } + + providerName, err := GetProviderName(c) + if err != nil { + return "", err + } + + provider, err := soth.GetProvider(providerName) + if err != nil { + return "", err + } + + sess, err := provider.BeginAuth(SetState(c)) + if err != nil { + return "", err + } + + url, err := sess.GetAuthURL() + if err != nil { + return "", err + } + + err = StoreInSession(providerName, sess.Marshal(), c) + if err != nil { + return "", err + } + + return url, err +} + +/* +CompleteUserAuth does what it says on the tin. It completes the authentication +process and fetches all of the basic information about the user from the provider. + +It expects to be able to get the name of the provider from the path parameter +":provider" or as set by SetProvider. + +This method automatically ends the session. You can prevent this behavior by +passing in options. Please note that any options provided in addition to the +first will be ignored. + +See https://github.com/markbates/goth/examples/main.go to see this in action. +*/ +func CompleteUserAuth(c *fiber.Ctx) (soth.User, error) { + if SessionStore == nil { + return soth.User{}, errors.New("session store unexpectedly nil") + } + + providerName, err := GetProviderName(c) + if err != nil { + return soth.User{}, err + } + + provider, err := soth.GetProvider(providerName) + if err != nil { + return soth.User{}, err + } + + value, err := GetFromSession(providerName, c) + if err != nil { + return soth.User{}, err + } + + sess, err := provider.UnmarshalSession(value) + if err != nil { + return soth.User{}, err + } + + err = validateState(c, sess) + if err != nil { + return soth.User{}, err + } + + user, err := provider.FetchUser(sess) + if err == nil { + // user can be found with existing session data + return user, err + } + + // get new token and retry fetch + _, err = sess.Authorize(provider, &Params{c: c}) + if err != nil { + return soth.User{}, err + } + + err = StoreInSession(providerName, sess.Marshal(), c) + if err != nil { + return soth.User{}, err + } + + gu, err := provider.FetchUser(sess) + return gu, err +} + +// validateState ensures that the state token param from the original +// AuthURL matches the one included in the current (callback) request. +func validateState(c *fiber.Ctx, sess soth.Session) error { + rawAuthURL, err := sess.GetAuthURL() + if err != nil { + return err + } + + authURL, err := url.Parse(rawAuthURL) + if err != nil { + return err + } + + originalState := authURL.Query().Get("state") + if originalState != "" && (originalState != c.Query("state")) { + return errors.New("state token mismatch") + } + return nil +} + +// Logout invalidates a user session. +func Logout(c *fiber.Ctx) error { + session, err := SessionStore.Get(c) + if err != nil { + return err + } + + if err := session.Destroy(); err != nil { + return err + } + + return nil +} + +// GetProviderName is a function used to get the name of a provider +// for a given request. By default, this provider is fetched from +// the URL query string. If you provide it in a different way, +// assign your own function to this variable that returns the provider +// name for your request. +func GetProviderName(c *fiber.Ctx) (string, error) { + // try to get it from the url param ":provider" + if p := c.Params("provider"); p != "" { + return p, nil + } + + // try to get it from the Fasthttp context's value of providerContextKey key + if p := c.Locals(fmt.Sprint(ProviderParamKey)); p != "" { + provider, ok := p.(string) + if ok && provider != "" { + return provider, nil + } + } + + // As a fallback, loop over the used providers, if we already have a valid session for any provider (ie. user has already begun authentication with a provider), then return that provider name + providers := soth.GetProviders() + session, err := SessionStore.Get(c) + if err != nil { + return "", err + } + + for _, provider := range providers { + p := provider.Name() + value := session.Get(p) + if _, ok := value.(string); ok { + return p, nil + } + } + + // if not found then return an empty string with the corresponding error + return "", errors.New("you must select a provider") +} + +func SetProvider(c *fiber.Ctx, provider string) { + c.Locals(fmt.Sprint(ProviderParamKey), provider) +} + +// StoreInSession stores a specified key/value pair in the session. +func StoreInSession(key string, value string, c *fiber.Ctx) error { + session, err := SessionStore.Get(c) + if err != nil { + return err + } + + if err := updateSessionValue(session, key, value); err != nil { + return err + } + + // saved here + return session.Save() +} + +// GetFromSession retrieves a previously-stored value from the session. +// If no value has previously been stored at the specified key, it will return an error. +func GetFromSession(key string, c *fiber.Ctx) (string, error) { + session, err := SessionStore.Get(c) + if err != nil { + return "", err + } + + value, err := getSessionValue(session, key) + if err != nil { + return "", errors.New("could not find a matching session for this request") + } + + return value, nil +} + +func getSessionValue(store *session.Session, key string) (string, error) { + value := store.Get(key) + if value == nil { + return "", errors.New("could not find a matching session for this request") + } + + rdata := strings.NewReader(value.(string)) + r, err := gzip.NewReader(rdata) + if err != nil { + return "", err + } + s, err := io.ReadAll(r) + if err != nil { + return "", err + } + + return string(s), nil +} + +func updateSessionValue(session *session.Session, key, value string) error { + var b bytes.Buffer + gz := gzip.NewWriter(&b) + if _, err := gz.Write([]byte(value)); err != nil { + return err + } + if err := gz.Flush(); err != nil { + return err + } + if err := gz.Close(); err != nil { + return err + } + + session.Set(key, b.String()) + + return nil +} diff --git a/backend/integrations/oauth/soth/user.go b/backend/integrations/oauth/soth/user.go new file mode 100644 index 000000000..16a321b42 --- /dev/null +++ b/backend/integrations/oauth/soth/user.go @@ -0,0 +1,42 @@ +package soth + +import ( + "encoding/gob" + "time" + + "github.com/GenerateNU/sac/backend/entities/models" +) + +func init() { + gob.Register(User{}) +} + +// User contains the information common amongst most OAuth and OAuth2 providers. +// All the "raw" data from the provider can be found in the `RawData` field. +type User struct { + RawData map[string]interface{} + Provider string + Email string + Name string + FirstName string + LastName string + NickName string + Description string + UserID string + AvatarURL string + Location string + AccessToken string + AccessTokenSecret string + RefreshToken string + ExpiresAt time.Time + IDToken string +} + +func (u *User) Into() *models.User { + return &models.User{ + Role: models.Student, + Name: u.Name, + Email: u.Email, + AvatarURL: u.AvatarURL, + } +} diff --git a/backend/locals/claims.go b/backend/locals/claims.go deleted file mode 100644 index 86f55273f..000000000 --- a/backend/locals/claims.go +++ /dev/null @@ -1,27 +0,0 @@ -package locals - -import ( - "fmt" - - "github.com/GenerateNU/sac/backend/auth" - "github.com/GenerateNU/sac/backend/utilities" - "github.com/gofiber/fiber/v2" -) - -func CustomClaimsFrom(c *fiber.Ctx) (*auth.CustomClaims, error) { - rawClaims := c.Locals(claimsKey) - if rawClaims == nil { - return nil, utilities.Forbidden() - } - - claims, ok := rawClaims.(*auth.CustomClaims) - if !ok { - return nil, fmt.Errorf("claims are not of type auth.CustomClaims. got: %T", rawClaims) - } - - return claims, nil -} - -func SetCustomClaims(c *fiber.Ctx, claims *auth.CustomClaims) { - c.Locals(claimsKey, claims) -} diff --git a/backend/locals/type.go b/backend/locals/type.go index 3c7c80364..bd23fa17a 100644 --- a/backend/locals/type.go +++ b/backend/locals/type.go @@ -3,6 +3,5 @@ package locals type localsKey byte const ( - claimsKey localsKey = 0 - userIDKey localsKey = 1 + userKey localsKey = 0 ) diff --git a/backend/locals/user.go b/backend/locals/user.go new file mode 100644 index 000000000..0397aeed2 --- /dev/null +++ b/backend/locals/user.go @@ -0,0 +1,31 @@ +package locals + +import ( + "fmt" + + "github.com/GenerateNU/sac/backend/entities/models" + "github.com/GenerateNU/sac/backend/utilities" + "github.com/gofiber/fiber/v2" +) + +func UserFrom(c *fiber.Ctx) (*models.User, error) { + lUser := c.Locals(userKey) + if lUser == nil { + return nil, utilities.Forbidden() + } + + user, ok := lUser.(*models.User) + if !ok { + return nil, fmt.Errorf("user is not of type models.User. got: %T", user) + } + + return user, nil +} + +func UserExists(c *fiber.Ctx) bool { + return c.Locals(userKey) != nil +} + +func SetUser(c *fiber.Ctx, user *models.User) { + c.Locals(userKey, user) +} diff --git a/backend/locals/user_id.go b/backend/locals/user_id.go deleted file mode 100644 index 8500a77be..000000000 --- a/backend/locals/user_id.go +++ /dev/null @@ -1,27 +0,0 @@ -package locals - -import ( - "fmt" - - "github.com/GenerateNU/sac/backend/utilities" - "github.com/gofiber/fiber/v2" - "github.com/google/uuid" -) - -func UserIDFrom(c *fiber.Ctx) (*uuid.UUID, error) { - userID := c.Locals(userIDKey) - if userID == nil { - return nil, utilities.Forbidden() - } - - id, ok := userID.(*uuid.UUID) - if !ok { - return nil, fmt.Errorf("userID is not of type uuid.UUID. got: %T", userID) - } - - return id, nil -} - -func SetUserID(c *fiber.Ctx, id *uuid.UUID) { - c.Locals(userIDKey, id) -} diff --git a/backend/main.go b/backend/main.go index b8b8f2cbe..b7c0771e6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,10 +4,16 @@ import ( "context" "flag" "fmt" + "log" "log/slog" "net" + "os" + "os/signal" "path/filepath" + "syscall" + "github.com/GenerateNU/sac/backend/background" + "github.com/GenerateNU/sac/backend/background/jobs" "github.com/GenerateNU/sac/backend/config" "github.com/GenerateNU/sac/backend/constants" "github.com/GenerateNU/sac/backend/database" @@ -16,18 +22,17 @@ import ( "github.com/GenerateNU/sac/backend/integrations" "github.com/GenerateNU/sac/backend/integrations/email" "github.com/GenerateNU/sac/backend/integrations/file" - "github.com/GenerateNU/sac/backend/integrations/oauth" "github.com/GenerateNU/sac/backend/server" "github.com/GenerateNU/sac/backend/telemetry" "github.com/GenerateNU/sac/backend/utilities" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + sdktrace "go.opentelemetry.io/otel/sdk/trace" ) func main() { - onlyMigrate := flag.Bool("only-migrate", false, "Specify if you want to only perform the database migration") - seedSearch := flag.Bool("seed-search", true, "Specify if you want to seed the opensearch nodes.") - configPath := flag.String("config", filepath.Join("..", "config", ".env.dev"), "Specify the path to the config file (.env)") - - flag.Parse() + onlyMigrate, seedSearch, configPath := parseFlags() config, err := config.GetConfiguration(*configPath) if err != nil { utilities.Exit("Error getting configuration: %s", err.Error()) @@ -39,6 +44,8 @@ func main() { utilities.Exit("A server is already running on %s:%d.\n", config.Application.Host, config.Application.Port) } + ctx := context.Background() + db, err := database.ConfigureDB(*config) if err != nil { utilities.Exit("Error migrating database: %s", err.Error()) @@ -49,41 +56,34 @@ func main() { } if *seedSearch { - slog.Info("to appease linter", "seedSearch", *seedSearch) - // if err := search.SeedClubs(db); err != nil { - // return - // } - - // if err := search.SeedEvents(db); err != nil { - // return - // } + seedSearchData(db) } - err = database.ConnPooling(db) - if err != nil { + if err := database.ConnPooling(db); err != nil { utilities.Exit("Error with connection pooling: %s", err.Error()) } - stores := store.ConfigureRedis(*config) + startBackgroundJobs(ctx, db) + stores := store.ConfigureStores(config.RedisLimiter) integrations := configureIntegrations(&config.Integrations) tp := telemetry.InitTracer() + defer shutdownTracer(tp) - slog.Info("appease linter since we aren't tracing anything yet", "tracer", telemetry.Tracer) + app := server.Init(db, stores, *integrations, *config) - defer func() { - if err := tp.Shutdown(context.Background()); err != nil { - slog.Error("error shutting down tracer", "error", err) - } - }() + go startServer(app, config.Application.Host, config.Application.Port) - app := server.Init(db, stores, *integrations, *config) + waitForShutdown(app) +} - err = app.Listen(fmt.Sprintf("%s:%d", config.Application.Host, config.Application.Port)) - if err != nil { - utilities.Exit("Error starting server: %s", err.Error()) - } +func parseFlags() (onlyMigrate, seedSearch *bool, configPath *string) { + onlyMigrate = flag.Bool("only-migrate", false, "Specify if you want to only perform the database migration") + seedSearch = flag.Bool("seed-search", true, "Specify if you want to seed the opensearch nodes.") + configPath = flag.String("config", filepath.Join("..", "config", ".env.dev"), "Specify the path to the config file (.env)") + flag.Parse() + return } func checkServerRunning(host string, port uint16) error { @@ -96,17 +96,48 @@ func checkServerRunning(host string, port uint16) error { return nil } +func seedSearchData(db *gorm.DB) { + slog.Info("to appease linter", "seedSearch", true, "db", db) + // if err := search.SeedClubs(db); err != nil { + // return + // } + + // if err := search.SeedEvents(db); err != nil { + // return + // } +} + +func startBackgroundJobs(ctx context.Context, db *gorm.DB) { + jobs := jobs.New(db) + background.Go(jobs.WelcomeSender(ctx)) +} + func configureIntegrations(config *config.Integrations) *integrations.Integrations { return &integrations.Integrations{ File: file.NewAWSProvider(config.AWS), Email: email.NewResendClient(config.Resend, true), - OAuth: oauth.OauthProviderSettings{ - Google: oauth.GoogleOAuthClient{ - OAuthConfig: config.GoogleOauth, - }, - Outlook: oauth.OutlookOAuthClient{ - OAuthConfig: config.OutlookOauth, - }, - }, } } + +func shutdownTracer(tp *sdktrace.TracerProvider) { + if err := tp.Shutdown(context.Background()); err != nil { + slog.Error("error shutting down tracer", "error", err) + } +} + +func startServer(app *fiber.App, host string, port uint16) { + if err := app.Listen(fmt.Sprintf("%s:%d", host, port)); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} + +func waitForShutdown(app *fiber.App) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + slog.Info("Shutting down server") + if err := app.Shutdown(); err != nil { + slog.Error("failed to shutdown server", "error", err) + } + slog.Info("Server shutdown") +} diff --git a/backend/middleware/auth/auth.go b/backend/middleware/auth/auth.go index df01b1e1c..1d59b9a7b 100644 --- a/backend/middleware/auth/auth.go +++ b/backend/middleware/auth/auth.go @@ -1,113 +1,42 @@ package auth import ( - "context" - "fmt" "slices" - "strings" + "time" - "github.com/GenerateNU/sac/backend/auth" - "github.com/GenerateNU/sac/backend/constants" - "github.com/GenerateNU/sac/backend/errs" - "github.com/GenerateNU/sac/backend/locals" - "github.com/GenerateNU/sac/backend/utilities" - "github.com/google/uuid" + "github.com/GenerateNU/sac/backend/permission" "github.com/GenerateNU/sac/backend/entities/models" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth/sothic" - "github.com/golang-jwt/jwt" + "github.com/GenerateNU/sac/backend/utilities" "github.com/gofiber/fiber/v2" ) -func (m *AuthMiddlewareService) IsSuper(c *fiber.Ctx) bool { - claims, err := locals.CustomClaimsFrom(c) - if err != nil { - return false - } - if claims == nil { - return false - } - return claims.Role == string(models.Super) -} - -func GetAuthorizationToken(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 { +func (m *AuthMiddlewareHandler) Authorize(requiredPermissions ...permission.Permission) fiber.Handler { return func(c *fiber.Ctx) error { - accessToken := GetAuthorizationToken(c) - if accessToken == nil { - return utilities.Unauthorized() - } - - token, err := func() (*jwt.Token, error) { - return jwt.ParseWithClaims(*accessToken, &auth.CustomClaims{}, func(token *jwt.Token) (interface{}, error) { - return []byte(m.Auth.AccessKey.Expose()), nil - }) - }() - if err != nil { - return utilities.Unauthorized() - } - - claims, ok := token.Claims.(*auth.CustomClaims) - if !ok || !token.Valid { - return utilities.Unauthorized() - } - - blacklistCtx, blacklistCancel := context.WithTimeoutCause(c.UserContext(), constants.REDIS_TIMEOUT, errs.ErrRedisTimeout) - defer blacklistCancel() - isBlacklisted, err := m.Stores.Blacklist.IsTokenBlacklisted(blacklistCtx, *accessToken) - if err != nil { - return utilities.InternalServerError() - } - - if isBlacklisted { - return utilities.Unauthorized() - } - - locals.SetCustomClaims(c, claims) - - rawUserID := claims.Issuer - userID, err := uuid.Parse(rawUserID) + strUser, err := sothic.GetFromSession("user", c) if err != nil { - return fmt.Errorf("invalid user id: %s", rawUserID) - } - - locals.SetUserID(c, &userID) - - return nil - }(c) -} + c.Cookie(&fiber.Cookie{ + Name: "redirect", + Value: c.OriginalURL(), + Expires: time.Now().Add(5 * time.Minute), + // MARK: secure should be true in prod + // use go build tags to do this + HTTPOnly: true, + }) -func (m *AuthMiddlewareService) Authorize(requiredPermissions ...auth.Permission) func(c *fiber.Ctx) error { - return func(c *fiber.Ctx) error { - authErr := m.Authenticate(c) - if authErr != nil { - return utilities.Unauthorized() + return c.Redirect("/api/v1/auth/login") } - claims, err := locals.CustomClaimsFrom(c) - if err != nil { - return err - } + user := models.UnmarshalUser(strUser) - if claims != nil && claims.Role == string(models.Super) { + if user.Role == models.Super { return c.Next() } - userPermissions := auth.GetPermissions(models.UserRole(claims.Role)) + userPermissions := permission.GetPermissions(user.Role) for _, requiredPermission := range requiredPermissions { if !slices.Contains(userPermissions, requiredPermission) { diff --git a/backend/middleware/auth/club.go b/backend/middleware/auth/club.go index 56ae9473d..f37b89cf4 100644 --- a/backend/middleware/auth/club.go +++ b/backend/middleware/auth/club.go @@ -2,43 +2,49 @@ package auth import ( "slices" + "time" "github.com/GenerateNU/sac/backend/entities/clubs" - "github.com/GenerateNU/sac/backend/locals" + "github.com/GenerateNU/sac/backend/entities/models" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth/sothic" "github.com/GenerateNU/sac/backend/utilities" "github.com/gofiber/fiber/v2" ) // Authorizes admins of the specific club to make this request, skips check if super user -func (m *AuthMiddlewareService) ClubAuthorizeById(c *fiber.Ctx, extractor ExtractID) error { - return func(c *fiber.Ctx) error { - if err := m.Authenticate(c); err != nil { - return utilities.Unauthorized() - } - - if m.IsSuper(c) { - return c.Next() - } - - clubUUID, err := extractor(c) - if err != nil { - return err - } - - userID, err := locals.UserIDFrom(c) - if err != nil { - return err - } - - clubAdmin, err := clubs.GetAdminIDs(m.DB, *clubUUID) - if err != nil { - return err - } - - if slices.Contains(clubAdmin, *userID) { - return c.Next() - } - - return utilities.Forbidden() - }(c) +func (m *AuthMiddlewareHandler) ClubAuthorizeById(c *fiber.Ctx, extractor ExtractID) error { + strUser, err := sothic.GetFromSession("user", c) + if err != nil { + c.Cookie(&fiber.Cookie{ + Name: "redirect", + Value: c.OriginalURL(), + Expires: time.Now().Add(5 * time.Minute), + // MARK: secure should be true in prod + // use go build tags to do this + HTTPOnly: true, + }) + return c.Redirect("/api/v1/auth/login") + } + + user := models.UnmarshalUser(strUser) + + if user.Role == models.Super { + return c.Next() + } + + clubUUID, err := extractor(c) + if err != nil { + return err + } + + clubAdmin, err := clubs.GetAdminIDs(m.db, *clubUUID) + if err != nil { + return err + } + + if slices.Contains(clubAdmin, user.ID) { + return c.Next() + } + + return utilities.Forbidden() } diff --git a/backend/middleware/auth/event.go b/backend/middleware/auth/event.go index c0fc03e9f..443c4b6ec 100644 --- a/backend/middleware/auth/event.go +++ b/backend/middleware/auth/event.go @@ -2,43 +2,47 @@ package auth import ( "slices" + "time" "github.com/GenerateNU/sac/backend/entities/events" - "github.com/GenerateNU/sac/backend/locals" + "github.com/GenerateNU/sac/backend/entities/models" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth/sothic" "github.com/GenerateNU/sac/backend/utilities" + "github.com/gofiber/fiber/v2" ) // Authorizes admins of the host club of this event to make this request, skips check if super user -func (m *AuthMiddlewareService) EventAuthorizeById(c *fiber.Ctx, extractor ExtractID) error { - return func(c *fiber.Ctx) error { - if err := m.Authenticate(c); err != nil { - return utilities.Unauthorized() - } - - if m.IsSuper(c) { - return c.Next() - } - - eventUUID, err := extractor(c) - if err != nil { - return err - } - - userID, err := locals.UserIDFrom(c) - if err != nil { - return err - } - - eventHostAdmin, err := events.GetEventHostAdminIDs(m.DB, *eventUUID) - if err != nil { - return err - } - - if slices.Contains(eventHostAdmin, *userID) { - return c.Next() - } - - return utilities.Forbidden() - }(c) +func (m *AuthMiddlewareHandler) EventAuthorizeById(c *fiber.Ctx, extractor ExtractID) error { + strUser, err := sothic.GetFromSession("user", c) + if err != nil { + c.Cookie(&fiber.Cookie{ + Name: "redirect", + Value: c.OriginalURL(), + Expires: time.Now().Add(5 * time.Minute), + // MARK: secure should be true in prod + // use go build tags to do this + HTTPOnly: true, + }) + + return c.Redirect("/api/v1/auth/login") + } + + user := models.UnmarshalUser(strUser) + + eventUUID, err := extractor(c) + if err != nil { + return err + } + + eventHostAdmin, err := events.GetEventHostAdminIDs(m.db, *eventUUID) + if err != nil { + return err + } + + if slices.Contains(eventHostAdmin, user.ID) { + return c.Next() + } + + return utilities.Forbidden() } diff --git a/backend/middleware/auth/middleware.go b/backend/middleware/auth/middleware.go index 22045ac85..08f61133e 100644 --- a/backend/middleware/auth/middleware.go +++ b/backend/middleware/auth/middleware.go @@ -1,34 +1,31 @@ package auth import ( - "github.com/GenerateNU/sac/backend/auth" - "github.com/GenerateNU/sac/backend/config" - "github.com/GenerateNU/sac/backend/database/store" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" + + "github.com/GenerateNU/sac/backend/permission" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) -type AuthMiddlewareInterface interface { - ClubAuthorizeById(c *fiber.Ctx) error +type AuthMiddlewareService interface { + ClubAuthorizeById(c *fiber.Ctx, extractor ExtractID) error UserAuthorizeById(c *fiber.Ctx) error - Authenticate(c *fiber.Ctx) error - Authorize(requiredPermissions ...auth.Permission) fiber.Handler - IsSuper(c *fiber.Ctx) bool + EventAuthorizeById(c *fiber.Ctx, extractor ExtractID) error + Authorize(requiredPermissions ...permission.Permission) fiber.Handler } -type AuthMiddlewareService struct { - DB *gorm.DB - Validate *validator.Validate - Auth config.AuthSettings - Stores *store.Stores +type AuthMiddlewareHandler struct { + db *gorm.DB + validate *validator.Validate + provider soth.Provider } -func New(db *gorm.DB, validate *validator.Validate, authSettings config.AuthSettings, stores *store.Stores) *AuthMiddlewareService { - return &AuthMiddlewareService{ - DB: db, - Validate: validate, - Auth: authSettings, - Stores: stores, +func New(db *gorm.DB, validate *validator.Validate, provider soth.Provider) AuthMiddlewareService { + return &AuthMiddlewareHandler{ + db: db, + validate: validate, + provider: provider, } } diff --git a/backend/middleware/auth/user.go b/backend/middleware/auth/user.go index 9c06cc2d8..38b65f33f 100644 --- a/backend/middleware/auth/user.go +++ b/backend/middleware/auth/user.go @@ -1,35 +1,43 @@ package auth import ( - "github.com/GenerateNU/sac/backend/locals" + "time" + + "github.com/GenerateNU/sac/backend/entities/models" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth/sothic" "github.com/GenerateNU/sac/backend/utilities" "github.com/gofiber/fiber/v2" ) -func (m *AuthMiddlewareService) UserAuthorizeById(c *fiber.Ctx) error { - return func(c *fiber.Ctx) error { - if err := m.Authenticate(c); err != nil { - return utilities.Unauthorized() - } +func (m *AuthMiddlewareHandler) UserAuthorizeById(c *fiber.Ctx) error { + strUser, err := sothic.GetFromSession("user", c) + if err != nil { + c.Cookie(&fiber.Cookie{ + Name: "redirect", + Value: c.OriginalURL(), + Expires: time.Now().Add(5 * time.Minute), + // MARK: secure should be true in prod + // use go build tags to do this + HTTPOnly: true, + }) + + return c.Redirect("/api/v1/auth/login") + } - if m.IsSuper(c) { - return c.Next() - } + user := models.UnmarshalUser(strUser) - idAsUUID, err := utilities.ValidateID(c.Params("userID")) - if err != nil { - return err - } + if user.Role == models.Super { + return c.Next() + } - userID, err := locals.UserIDFrom(c) - if err != nil { - return err - } + idAsUUID, err := utilities.ValidateID(c.Params("userID")) + if err != nil { + return err + } - if idAsUUID == userID { - return c.Next() - } + if idAsUUID == &user.ID { + return c.Next() + } - return utilities.Forbidden() - }(c) + return utilities.Forbidden() } diff --git a/backend/middleware/utility/limiter.go b/backend/middleware/utility/limiter.go index 39d29bf30..84f88f510 100644 --- a/backend/middleware/utility/limiter.go +++ b/backend/middleware/utility/limiter.go @@ -11,7 +11,7 @@ import ( "github.com/gofiber/fiber/v2/middleware/limiter" ) -func (u *UtilityMiddlewareService) Limiter(rate int, expiration time.Duration) func(c *fiber.Ctx) error { +func (h *Handler) Limiter(rate int, expiration time.Duration) func(c *fiber.Ctx) error { return limiter.New(limiter.Config{ Max: rate, Expiration: expiration, @@ -21,6 +21,6 @@ func (u *UtilityMiddlewareService) Limiter(rate int, expiration time.Duration) f LimitReached: func(c *fiber.Ctx) error { return utilities.NewAPIError(http.StatusTooManyRequests, errors.New("too many requests")) }, - Storage: u.Stores.Limiter, + Storage: h.limiter, }) } diff --git a/backend/middleware/utility/middleware.go b/backend/middleware/utility/middleware.go index 2f582e913..36351ae8e 100644 --- a/backend/middleware/utility/middleware.go +++ b/backend/middleware/utility/middleware.go @@ -7,19 +7,19 @@ import ( "github.com/gofiber/fiber/v2" ) -type UtilityMiddlewareInterface interface { +type Service interface { Paginator(c *fiber.Ctx) error Limiter(rate int, duration time.Duration) fiber.Handler } -type UtilityMiddlewareService struct { +type Handler struct { paginator fiber.Handler - Stores *store.Stores + limiter store.Storer } -func New(paginator fiber.Handler, stores *store.Stores) *UtilityMiddlewareService { - return &UtilityMiddlewareService{ +func New(paginator fiber.Handler, limiter store.Storer) Service { + return &Handler{ paginator: paginator, - Stores: stores, + limiter: limiter, } } diff --git a/backend/middleware/utility/paginator.go b/backend/middleware/utility/paginator.go index 0e2827fda..fc54b851e 100644 --- a/backend/middleware/utility/paginator.go +++ b/backend/middleware/utility/paginator.go @@ -4,6 +4,6 @@ import ( "github.com/gofiber/fiber/v2" ) -func (u *UtilityMiddlewareService) Paginator(c *fiber.Ctx) error { - return u.paginator(c) +func (h *Handler) Paginator(c *fiber.Ctx) error { + return h.paginator(c) } diff --git a/backend/migrations/000001_init.down.sql b/backend/migrations/000001_init.down.sql index 7b7223876..2a37eecb7 100644 --- a/backend/migrations/000001_init.down.sql +++ b/backend/migrations/000001_init.down.sql @@ -1,132 +1,5 @@ BEGIN; -ALTER TABLE - "users" -ALTER COLUMN - id DROP DEFAULT; - -ALTER TABLE - recruitment -ALTER COLUMN - id DROP DEFAULT; - -ALTER TABLE - applications -ALTER COLUMN - id DROP DEFAULT; - -ALTER TABLE - clubs -ALTER COLUMN - id DROP DEFAULT; - -ALTER TABLE - series -ALTER COLUMN - id DROP DEFAULT; - -ALTER TABLE - EVENTS -ALTER COLUMN - id DROP DEFAULT; - -ALTER TABLE - categories -ALTER COLUMN - id DROP DEFAULT; - -ALTER TABLE - tags -ALTER COLUMN - id DROP DEFAULT; - -ALTER TABLE - club_events -ALTER COLUMN - club_id DROP DEFAULT, -ALTER COLUMN - event_id DROP DEFAULT; - -ALTER TABLE - club_tags -ALTER COLUMN - tag_id DROP DEFAULT, -ALTER COLUMN - club_id DROP DEFAULT; - -ALTER TABLE - contacts -ALTER COLUMN - id DROP DEFAULT; - -ALTER TABLE - event_tags -ALTER COLUMN - tag_id DROP DEFAULT, -ALTER COLUMN - event_id DROP DEFAULT; - -ALTER TABLE - files -ALTER COLUMN - id DROP DEFAULT; - --- ALTER TABLE notifications ALTER COLUMN id DROP DEFAULT; -- Uncomment if notifications table is created -ALTER TABLE - leadership -ALTER COLUMN - id DROP DEFAULT; - -ALTER TABLE - user_club_followers -ALTER COLUMN - user_id DROP DEFAULT, -ALTER COLUMN - club_id DROP DEFAULT; - -ALTER TABLE - user_club_intended_applicants -ALTER COLUMN - user_id DROP DEFAULT, -ALTER COLUMN - club_id DROP DEFAULT; - -ALTER TABLE - user_club_members -ALTER COLUMN - user_id DROP DEFAULT; - -ALTER TABLE - user_event_rsvps -ALTER COLUMN - user_id DROP DEFAULT, -ALTER COLUMN - event_id DROP DEFAULT; - -ALTER TABLE - user_event_waitlists -ALTER COLUMN - user_id DROP DEFAULT, -ALTER COLUMN - event_id DROP DEFAULT; - -ALTER TABLE - user_tags -ALTER COLUMN - user_id DROP DEFAULT, -ALTER COLUMN - tag_id DROP DEFAULT; - -ALTER TABLE - verifications -ALTER COLUMN - user_id DROP DEFAULT; - -ALTER TABLE - user_oauth_tokens -ALTER COLUMN - user_id DROP DEFAULT; - DROP TABLE IF EXISTS club_events CASCADE; DROP TABLE IF EXISTS club_tags CASCADE; @@ -152,11 +25,7 @@ DROP TABLE IF EXISTS user_event_waitlists CASCADE; DROP TABLE IF EXISTS user_tags CASCADE; -DROP TABLE IF EXISTS verifications CASCADE; - -DROP TABLE IF EXISTS user_oauth_tokens CASCADE; - -DROP TABLE IF EXISTS EVENTS CASCADE; +DROP TABLE IF EXISTS "events" CASCADE; DROP TABLE IF EXISTS series CASCADE; @@ -172,14 +41,14 @@ DROP TABLE IF EXISTS recruitment CASCADE; DROP TABLE IF EXISTS "users" CASCADE; +DROP TABLE IF EXISTS welcome_tasks CASCADE; + DROP TYPE IF EXISTS recruitment_cycle CASCADE; DROP TYPE IF EXISTS recruitment_type CASCADE; DROP TYPE IF EXISTS event_type CASCADE; -DROP TYPE IF EXISTS OAuthResourceType CASCADE; - DROP EXTENSION IF EXISTS "uuid-ossp"; COMMIT; \ No newline at end of file diff --git a/backend/migrations/000001_init.up.sql b/backend/migrations/000001_init.up.sql index f62171440..6af707ab5 100644 --- a/backend/migrations/000001_init.up.sql +++ b/backend/migrations/000001_init.up.sql @@ -7,17 +7,15 @@ CREATE TABLE IF NOT EXISTS "users"( created_at timestamp WITH time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamp WITH time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, role varchar(255) NOT NULL DEFAULT 'student' :: character varying, - first_name varchar(255) NOT NULL, - last_name varchar(255) NOT NULL, + name varchar(255) NOT NULL, email varchar(255) NOT NULL, - password_hash varchar(97) NOT NULL, major0 varchar(255), major1 varchar(255), major2 varchar(255), college varchar(255), graduation_cycle varchar(255), graduation_year smallint, - is_verified boolean NOT NULL DEFAULT false, + avatar_url varchar(255), PRIMARY KEY(id) ); @@ -267,28 +265,11 @@ CREATE TABLE IF NOT EXISTS user_tags( CONSTRAINT fk_user_tags_tag FOREIGN KEY(tag_id) REFERENCES tags(id) ON UPDATE CASCADE ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uni_users_email ON "users" USING btree ("email"); - -CREATE TABLE IF NOT EXISTS verifications( - user_id uuid NOT NULL, - token varchar(255), - expires_at timestamp WITH time zone NOT NULL, - "type" varchar(255) NOT NULL, - PRIMARY KEY(user_id, expires_at) -); - -CREATE UNIQUE INDEX IF NOT EXISTS uni_verifications_token ON verifications USING btree ("token"); - -CREATE TYPE OAuthResourceType AS ENUM ('google', 'outlook'); - -CREATE TABLE IF NOT EXISTS user_oauth_tokens( - user_id uuid NOT NULL, - refresh_token varchar, - access_token varchar, - csrf_token varchar(255), - resource_type OAuthResourceType, - expires_at timestamp WITH time zone NOT NULL, - PRIMARY KEY(user_id, resource_type) +CREATE TABLE IF NOT EXISTS welcome_tasks( + email varchar(255) NOT NULL, + name varchar(255) NOT NULL, + attempts integer NOT NULL DEFAULT 0, + PRIMARY KEY(email) ); COMMIT; \ No newline at end of file diff --git a/backend/auth/permissions.go b/backend/permission/permission.go similarity index 99% rename from backend/auth/permissions.go rename to backend/permission/permission.go index e07bc61bf..7fe6484ca 100644 --- a/backend/auth/permissions.go +++ b/backend/permission/permission.go @@ -1,4 +1,4 @@ -package auth +package permission import "github.com/GenerateNU/sac/backend/entities/models" diff --git a/backend/server/server.go b/backend/server/server.go index bf466e1f1..a916d37c5 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -6,17 +6,18 @@ import ( "github.com/garrettladley/fiberpaginate" go_json "github.com/goccy/go-json" - authenticator "github.com/GenerateNU/sac/backend/auth" "github.com/GenerateNU/sac/backend/config" "github.com/GenerateNU/sac/backend/database/store" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth/goog" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth/msft" + auth "github.com/GenerateNU/sac/backend/entities/auth/base" categories "github.com/GenerateNU/sac/backend/entities/categories/base" clubs "github.com/GenerateNU/sac/backend/entities/clubs/base" events "github.com/GenerateNU/sac/backend/entities/events/base" files "github.com/GenerateNU/sac/backend/entities/files/base" leader "github.com/GenerateNU/sac/backend/entities/leadership/base" - oauth "github.com/GenerateNU/sac/backend/entities/oauth/base" socials "github.com/GenerateNU/sac/backend/entities/socials/base" tags "github.com/GenerateNU/sac/backend/entities/tags/base" users "github.com/GenerateNU/sac/backend/entities/users/base" @@ -33,7 +34,6 @@ 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" ) @@ -55,9 +55,18 @@ func Init(db *gorm.DB, stores *store.Stores, integrations integrations.Integrati panic(fmt.Sprintf("Error registering custom validators: %s", err)) } - jwt := authenticator.NewJWTClient(settings.Auth, jwt.SigningMethodHS256) - authMiddleware := authMiddleware.New(db, validate, settings.Auth, stores) - utilityMiddleware := utilityMiddleware.New(fiberpaginate.New(), stores) + applicationURL := settings.Application.ApplicationURL() + + msftProvider := msft.New(settings.Microsft.Key, settings.Microsft.Secret, fmt.Sprintf("%s/api/v1/auth/microsoftonline/callback", applicationURL), settings.Microsft.Tenant) + googProvider := goog.New(settings.Google.Key, settings.Google.Secret, fmt.Sprintf("%s/api/v1/auth/google/callback", applicationURL)) + + authMiddleware := authMiddleware.New( + db, + validate, + msftProvider, + ) + + utilityMiddleware := utilityMiddleware.New(fiberpaginate.New(), stores.Limiter) apiv1 := app.Group("/api/v1") @@ -68,22 +77,29 @@ func Init(db *gorm.DB, stores *store.Stores, integrations integrations.Integrati types.NewServiceParams( db, validate, - &settings.Auth, - jwt, &settings.Calendar, stores, integrations, ), ) - allRoutes(app, routeParams) + allRoutes(app, routeParams, + auth.NewParams( + msftProvider, + applicationURL, + apiv1, + db, + integrations.Email, + validate, + googProvider, + )) return app } -func allRoutes(app *fiber.App, routeParams types.RouteParams) { +func allRoutes(app *fiber.App, routeParams types.RouteParams, authParams auth.Params) { Utility(app) - auth.Auth(routeParams) + auth.Auth(authParams) users.UserRoutes(routeParams) clubs.ClubRoutes(routeParams) socials.Social(routeParams) @@ -93,7 +109,6 @@ func allRoutes(app *fiber.App, routeParams types.RouteParams) { events.EventRoutes(routeParams) files.File(routeParams) search.SearchRoutes(routeParams) - oauth.OAuth(routeParams) } func newFiberApp(appSettings config.ApplicationSettings) *fiber.App { @@ -104,7 +119,7 @@ func newFiberApp(appSettings config.ApplicationSettings) *fiber.App { }) app.Use(cors.New(cors.Config{ - AllowOrigins: fmt.Sprintf("http://%s:%d", appSettings.Host, appSettings.Port), + AllowOrigins: appSettings.ApplicationURL(), AllowCredentials: true, AllowHeaders: "Origin, Content-Type, Accept, Authorization", AllowMethods: "GET, POST, PUT, DELETE, OPTIONS", diff --git a/backend/templates/emails/password_change_complete.templ b/backend/templates/emails/password_change_complete.templ deleted file mode 100644 index 5e21dadde..000000000 --- a/backend/templates/emails/password_change_complete.templ +++ /dev/null @@ -1,74 +0,0 @@ -package emails - -import "github.com/GenerateNU/sac/backend/templates/emails/layouts" - -templ PasswordChangeComplete(name string) { - @layouts.Base("Your Hippo Password Has Been Reset", passwordChangeCompleteStyles()) { -
Hi { name },
-- This email confirms that your password for your Hippo account has been - successfully reset. -
-You can now sign in using your new password:
- Sign in to Hippo -- If you did not request this password reset, please contact our support - team immediately. -
-Sincerely,
-The Hippo Team
-Hi ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/emails/password_change_complete.templ`, Line: 9, Col: 15} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(",
This email confirms that your password for your Hippo account has been successfully reset.
You can now sign in using your new password:
Sign in to HippoIf you did not request this password reset, please contact our support team immediately.
Sincerely,
The Hippo Team
Hi { name },
-- Looks like you've misplaced the key to your Hippo account! Don't worry, - we can help you unlock it in no time. Just click the button below to - choose a new password: -
- Reset Your Password -- Remember, if you didn't request this change, simply disregard this - message and your account remains secure. -
-See you soon,
-The Hippo Team
-Hi ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/emails/password_reset.templ`, Line: 11, Col: 15} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(",
Looks like you've misplaced the key to your Hippo account! Don't worry, we can help you unlock it in no time. Just click the button below to choose a new password:
Reset Your PasswordRemember, if you didn't request this change, simply disregard this message and your account remains secure.
See you soon,
The Hippo Team