// The controllers.core package should act as the prime pin controlling // User or Admin activity throughout the rest of the site. It should offer // Login lifecycle methods, as well as per-request JWT-based authentication. package core import ( "fmt" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/yxzzy-wtf/gin-gonic-prepack/config" "github.com/yxzzy-wtf/gin-gonic-prepack/database" "github.com/yxzzy-wtf/gin-gonic-prepack/models" "github.com/yxzzy-wtf/gin-gonic-prepack/util" ) // Basic structure for login calls to both Admin and User access type login struct { UserKey string `json:"userkey" binding:"required,email"` Password string `json:"password" binding:"required"` TwoFactor string `json:"twofactorcode"` } // Basic structure for users to sign up based on models.User type signup struct { UserKey string `json:"userkey" binding:"required,email"` Password string `json:"password" binding:"required"` } // Body query responsible for requesting a password reset type forgotten struct { UserKey string `json:"userkey" binding:"required,email"` } // Body query responsible for resetting a password with a reset token type reset struct { Token string `json:"token" binding:"required"` NewPassword string `json:"password" binding:"required"` } // The default name of the JWT header expected. It is localized and can be changed // to anything valid here const JwtHeader = "jwt" // User signup process. Tests that an email and password has been supplied, that // the email is unique and that the password is valid, then creates the user and // sends a verification email to the given email address. To prevent enumeration // attacks, this method will always return {next:"verification pending"}, even // if the given email is already in the system. If the email is already in the system, // that account will be emailed notifying them of the signup attempt. func UserSignup() gin.HandlerFunc { return func(c *gin.Context) { var signupVals signup if err := c.ShouldBind(&signupVals); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, util.FailMsg{Reason: "invalid fields, requires userkey=email and password"}) return } u := models.User{ Email: signupVals.UserKey, } if err := u.SetPassword(signupVals.Password); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, util.FailMsg{Reason: "bad password"}) return } if err := u.Create(); err != nil { if err.Error() != "UNIQUE constraint failed: users.email" { fmt.Println(fmt.Errorf("error: %w", err)) c.AbortWithStatus(http.StatusInternalServerError) return } else { // Email conflict means we should still mock verify go util.SendEmail("Signup Attempt", "Someone tried to sign up with this email. This is a cursory warning. If it was you, good news! You're already signed up!", []string{u.Email}) } } else { // Send verification verifyToken := u.GetVerificationJwt() go util.SendEmail("Verify Email", "Helloooo! Go here to verify: http://localhost:9091/v1/verify?verify="+verifyToken, []string{u.Email}) } c.JSON(http.StatusOK, util.NextMsg{Next: "verification pending"}) } } // Function to log in a user based on a given email, password [and 2FA code]. Similar to // AdminLogin but with slight differences. Resistant to enumeration attacks as error messages // are only displayed IFF the user exists AND the password is correct, otherwise a 401 is returned func UserLogin() gin.HandlerFunc { return func(c *gin.Context) { // Why do we do this? Assuming a consistent and stable service and an attacker // with an equally consistent internet connection, it is possible to // still launch an enumeration attack by comparing the time of a known // extant address and a known non-extant one. For this reason, login duration is // floored to at least 5 seconds minTime := make(chan bool) go func(c chan bool) { time.Sleep(time.Second * 5) minTime <- true }(minTime) var loginVals login if err := c.ShouldBind(&loginVals); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, util.FailMsg{Reason: "Requires username and password"}) <-minTime return } u := models.User{} if err := u.ByEmail(loginVals.UserKey); err != nil { c.AbortWithStatus(http.StatusUnauthorized) <-minTime return } if err, returnErr := u.Login(loginVals.Password, loginVals.TwoFactor); err != nil { if returnErr { c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: err.Error()}) } else { c.AbortWithStatus(http.StatusUnauthorized) } <-minTime return } if loginVals.TwoFactor != "" && !checkTwoFactorNotReused(&u.Auth, loginVals.TwoFactor) { fmt.Printf("WARNING: two factor code %v reused for %v\n", loginVals.TwoFactor, u.Uid) c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "2fa reused"}) <-minTime return } jwt, maxAge := u.GetJwt() c.SetCookie(JwtHeader, jwt, maxAge, "/v1/sec/", "", true, true) <-minTime } } // Parses a given JWT token and attempts to verify the `sub` in that token IFF // the token role == "verify". Verifying an already-verified user returns // a 200OK{next:"login"} without any action func UserVerify() gin.HandlerFunc { return func(c *gin.Context) { verifyJwt, _ := c.GetQuery("verify") claims, err := util.ParseJwt(verifyJwt, models.UserHmac) if err != nil || claims["role"] != "verify" { fmt.Println("bad claim or role not 'verify'", err) c.AbortWithStatus(http.StatusUnauthorized) return } // Yay! Jwt is a verify token, let's verify the linked user uid, err := uuid.Parse(claims["sub"].(string)) if err != nil { fmt.Println("sub should ALWAYS be valid uuid at this point??", err) c.AbortWithStatus(http.StatusUnauthorized) return } verifying := models.User{ Auth: models.Auth{ Base: models.Base{ Uid: uid, }, }, } if err := database.Db.Find(&verifying).Error; err != nil { fmt.Println("could not find user", err) c.AbortWithStatus(http.StatusUnauthorized) return } if verifying.Verified { // User already verified c.JSON(http.StatusOK, util.NextMsg{Next: "verified"}) return } verifying.Verified = true if err := verifying.Save(); err != nil { fmt.Println("could not verify user", err) c.AbortWithStatus(http.StatusInternalServerError) return } c.JSON(http.StatusOK, util.NextMsg{Next: "login"}) } } // Indicates to the service that the user has forgotten their password and // requires a reset token; then sends an email with the appropriate reset token // to the user email in question. The same response will be returned if the given // user email does not exist func UserForgotPassword() gin.HandlerFunc { return func(c *gin.Context) { var forgotVals forgotten if err := c.ShouldBind(&forgotVals); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, util.FailMsg{Reason: "requires email"}) return } u := models.User{} if err := u.ByEmail(forgotVals.UserKey); err == nil { // Actually send renew token forgotJwt := u.GetResetPasswordJwt() go util.SendEmail("Forgot Password", "Token to reset password: "+forgotJwt, []string{u.Email}) } c.JSON(http.StatusOK, util.NextMsg{Next: "check email to reset"}) } } // Method to reset a password, requiring a new password and a valid JWT token // of role="reset". func UserResetForgottenPassword() gin.HandlerFunc { return func(c *gin.Context) { var resetVals reset if err := c.ShouldBind(&resetVals); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, util.FailMsg{Reason: "requires new pass and token"}) return } claims, err := util.ParseJwt(resetVals.Token, models.UserHmac) if err != nil || claims["role"] != "reset" { fmt.Println("bad claim or role not 'reset'", err) c.AbortWithStatus(http.StatusUnauthorized) return } uid, err := uuid.Parse(claims["sub"].(string)) if err != nil { fmt.Println("sub should ALWAYS be valid uuid at this point??", err) c.AbortWithStatus(http.StatusUnauthorized) return } resetting := models.User{ Auth: models.Auth{ Base: models.Base{ Uid: uid, }, }, } if err := database.Db.Find(&resetting).Error; err != nil { fmt.Println("could not find user", err) c.AbortWithStatus(http.StatusUnauthorized) return } resetting.SetPassword(resetVals.NewPassword) if err := resetting.Save(); err != nil { fmt.Println("could not save user", err) c.AbortWithStatus(http.StatusUnauthorized) return } c.JSON(http.StatusOK, util.NextMsg{Next: "login"}) } } // Admin login functionality, similar to user login but requires 2FA to be set up. func AdminLogin() gin.HandlerFunc { return func(c *gin.Context) { // Same as user slowdown minTime := make(chan bool) go func(c chan bool) { time.Sleep(time.Second * 5) minTime <- true }(minTime) var loginVals login if err := c.ShouldBind(&loginVals); err != nil || loginVals.TwoFactor == "" { c.AbortWithStatusJSON(http.StatusBadRequest, util.FailMsg{Reason: "Requires username, 2FA and password"}) <-minTime return } a := models.Admin{} if err := a.ByEmail(loginVals.UserKey); err != nil { c.AbortWithStatus(http.StatusUnauthorized) <-minTime return } if err, returnErr := a.Login(loginVals.Password, loginVals.TwoFactor); err != nil { if returnErr { c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: err.Error()}) } else { c.AbortWithStatus(http.StatusUnauthorized) } <-minTime return } if loginVals.TwoFactor != "" && !checkTwoFactorNotReused(&a.Auth, loginVals.TwoFactor) { fmt.Printf("WARNING: two factor code %v reused by admin %v\n", loginVals.TwoFactor, a.Uid) c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "2fa reused"}) <-minTime return } jwt, maxAge := a.GetJwt() c.SetCookie(JwtHeader, jwt, maxAge, "/v1/sec/", "", true, true) <-minTime } } // Generic authorization applicable to both User and Admin roles. This takes an // expected role and HMAC, parses the JWT, and sets the PrincipalInfo accordingly. func genericAuth(expectedRole string, hmac []byte) gin.HandlerFunc { return func(c *gin.Context) { tokenStr := c.GetHeader(JwtHeader) if tokenStr == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "requires `" + JwtHeader + "` header"}) return } claims, err := util.ParseJwt(tokenStr, hmac) if err != nil { if strings.HasPrefix(err.Error(), "token ") || err.Error() == "signature is invalid" { c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: err.Error()}) } else { fmt.Println(err) c.AbortWithStatusJSON(http.StatusInternalServerError, util.FailMsg{Reason: "something went wrong"}) } return } if claims["role"] != expectedRole { c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "wrong access role"}) return } uid, err := uuid.Parse(claims["sub"].(string)) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "cannot extract sub"}) return } c.Set("principal", util.PrincipalInfo{Uid: uid, Role: expectedRole}) } } // Wrapper for User authentication, effectively `genericAuth("user", models.UserHmac)` func UserAuth() gin.HandlerFunc { return genericAuth("user", models.UserHmac) } // Wrapper for User authentication, effectively `genericAuth("admin", models.AdminHmac)` func AdminAuth() gin.HandlerFunc { return genericAuth("admin", models.AdminHmac) } // A handler to attach to any method which requires a two-factor check // at the time of calling. An example of this might be: changing email, // changing password, or other high-sensitivity actions that warrant // an extra 2FA check. func LiveTwoFactor() gin.HandlerFunc { return func(c *gin.Context) { pif, exists := c.Get("principal") p := pif.(util.PrincipalInfo) if !exists { c.AbortWithStatus(http.StatusUnauthorized) return } var a models.Auth if p.Role == "user" { u := models.User{} if err := database.Db.Find(&u, "uid = ?", p.Uid).Error; err != nil { c.AbortWithStatus(http.StatusUnauthorized) return } a = u.Auth } else if p.Role == "admin" { adm := models.Admin{} if err := database.Db.Find(&adm, "uid = ?", p.Uid).Error; err != nil { c.AbortWithStatus(http.StatusUnauthorized) return } a = adm.Auth } if a.TwoFactorSecret != "" { tfCode, exists := c.GetQuery("twofactorcode") if !exists || len(tfCode) != 6 { c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "2fa required"}) return } if err := a.ValidateTwoFactor(tfCode, time.Now()); err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: err.Error()}) return } if !checkTwoFactorNotReused(&a, tfCode) { c.AbortWithStatusJSON(http.StatusUnauthorized, util.FailMsg{Reason: "2fa reused"}) return } } } } func StarterAdmin() gin.HandlerFunc { return func(c *gin.Context) { var count int64 database.Db.Model(&models.Admin{}).Count(&count) if count != 0 { c.AbortWithStatus(http.StatusUnauthorized) return } var signupVals signup if err := c.ShouldBind(&signupVals); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, util.FailMsg{Reason: "invalid fields, requires userkey=email and password"}) return } a := models.Admin{} if err := a.ByEmail(signupVals.UserKey); err == nil { c.AbortWithStatus(http.StatusUnauthorized) return } a.Email = signupVals.UserKey a.SetPassword(signupVals.Password) a.GenerateNewTwoFactorSecret() if err := database.Db.Create(&a).Error; err != nil { c.AbortWithStatus(http.StatusInternalServerError) return } go util.SendEmail("Admin Created", "A new admin, "+a.Email+", has been created", config.Config.AdminEmails) c.JSON(http.StatusOK, util.NextMsg{Next: "db verify"}) } } // A simple context-aware ping method. If there is a "principal" in this request context // it will indicate the principal Uid and role in the response func Doot() gin.HandlerFunc { return func(c *gin.Context) { piCtx, exists := c.Get("principal") if exists { pi := piCtx.(util.PrincipalInfo) dooter := pi.Role + ":" + pi.Uid.String() c.JSON(http.StatusOK, map[string]string{"snoot": "dooted by " + dooter}) } else { c.JSON(http.StatusOK, map[string]string{"snoot": "dooted"}) } } } // To prevent 2FA theft attacks, a TOTP needs to be... well, an OTP. This will // check the database to confirm that this user UID has never used this token in // past, and will fail if such a token has been used by this user. It will then // add this UID:code combination to the TotpUsage table to prevent future re-use. // There may be some sense in adding a means by which to clear out TotpUsage objects // older than a certain time. func checkTwoFactorNotReused(a *models.Auth, tfCode string) bool { var count int64 database.Db.Model(&models.TotpUsage{}).Where("login_uid = ? AND code = ?", a.Uid, tfCode).Count(&count) if count > 0 { // We found a token, should reject return false } used := models.TotpUsage{ LoginUid: a.Uid, Code: tfCode, Used: time.Now(), } go database.Db.Create(&used) return true }