dkforest

A forum and chat platform (onion)
git clone https://git.dasho.dev/n0tr1v/dkforest.git
Log | Files | Refs | LICENSE

commit 2b458a6ebd52958e9b88045c2c739517f44bbd68
parent 91701b7e9107640b7de15dee9be484c64c181ece
Author: n0tr1v <n0tr1v@protonmail.com>
Date:   Mon, 12 Jun 2023 20:29:52 -0700

move code

Diffstat:
Mpkg/web/handlers/handlers.go | 649-------------------------------------------------------------------------------
Apkg/web/handlers/login.go | 626+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/web/handlers/signup.go | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 678 insertions(+), 649 deletions(-)

diff --git a/pkg/web/handlers/handlers.go b/pkg/web/handlers/handlers.go @@ -48,41 +48,6 @@ import ( "golang.org/x/crypto/bcrypt" ) -func firstUseHandler(c echo.Context) error { - user := c.Get("authUser").(*database.User) - db := c.Get("database").(*database.DkfDB) - var data firstUseData - if user != nil { - return c.Redirect(http.StatusFound, "/") - } - - if c.Request().Method == http.MethodGet { - //data.Username = "admin" - //data.Password = "admin123" - //data.RePassword = "admin123" - //data.Email = "admin@admin.admin" - return c.Render(http.StatusOK, "standalone.first-use", data) - } - - data.Username = c.Request().PostFormValue("username") - data.Password = c.Request().PostFormValue("password") - data.RePassword = c.Request().PostFormValue("repassword") - newUser, errs := db.CreateFirstUser(data.Username, data.Password, data.RePassword) - data.Errors = errs - if errs.HasError() { - return c.Render(http.StatusOK, "standalone.first-use", data) - } - - _, errs = db.CreateZeroUser() - - config.IsFirstUse.SetFalse() - - session := db.DoCreateSession(newUser.ID, c.Request().UserAgent(), time.Hour*24*30) - c.SetCookie(createSessionCookie(session.Token, time.Hour*24*30)) - - return c.Redirect(http.StatusFound, "/") -} - var tempLoginCache = cache.New[TempLoginCaptcha](3*time.Minute, 3*time.Minute) var tempLoginStore = captcha.NewMemoryStore(captcha.CollectNum, 3*time.Minute) @@ -232,398 +197,6 @@ func NewPartialAuthItem(userID database.UserID, step PartialAuthStep, sessionDur type PartialAuthStep string -const ( - TwoFactorStep PartialAuthStep = "2fa" - PgpSignStep PartialAuthStep = "pgp_sign_2fa" - PgpStep PartialAuthStep = "pgp_2fa" -) - -func LoginHandler(c echo.Context) error { - - if config.ProtectHome.IsTrue() { - return c.NoContent(http.StatusNotFound) - } - - return loginHandler(c) -} - -func LoginAttackHandler(c echo.Context) error { - key := c.Param("loginToken") - loginLink, found := tempLoginCache.Get("login_link") - if !found { - return c.NoContent(http.StatusNotFound) - } - // We use the "Dangerous" version of VerifyString, to avoid invalidating the captcha. - // This way, the captcha can be used multiple times by different users until it's time has expired. - if err := captcha.VerifyStringDangerous(tempLoginStore, loginLink.ID, key); err != nil { - // If the captcha was invalid, kill the circuit. - hutils.KillCircuit(c) - time.Sleep(utils.RandSec(3, 5)) - return c.NoContent(http.StatusNotFound) - } - - return loginHandler(c) -} - -func loginFormHandler(c echo.Context) error { - db := c.Get("database").(*database.DkfDB) - var data loginData - data.Autofocus = 0 - data.HomeUsersList = config.HomeUsersList.Load() - - if data.HomeUsersList { - data.Online = managers.ActiveUsers.GetActiveUsers() - } - - actualLogin := func(username, password string, sessionDuration time.Duration, captchaSolved bool) error { - username = strings.TrimSpace(username) - user, err := db.GetVerifiedUserByUsername(database.Username(username)) - if err != nil { - time.Sleep(utils.RandMs(50, 200)) - data.Error = "Invalid username/password" - return c.Render(http.StatusOK, "standalone.login", data) - } - - user.LoginAttempts++ - user.DoSave(db) - - if user.LoginAttempts > 4 && !captchaSolved { - data.CaptchaRequired = true - data.Autofocus = 2 - data.Error = "Captcha required" - data.CaptchaID, data.CaptchaImg = captcha.New() - data.Password = password - captchaID := c.Request().PostFormValue("captcha_id") - captchaInput := c.Request().PostFormValue("captcha") - if captchaInput == "" { - return c.Render(http.StatusOK, "standalone.login", data) - } else { - if err := hutils.CaptchaVerifyString(c, captchaID, captchaInput); err != nil { - data.Error = "Invalid captcha" - return c.Render(http.StatusOK, "standalone.login", data) - } - } - } - - if !user.CheckPassword(db, password) { - data.Password = "" - data.Autofocus = 1 - data.Error = "Invalid username/password" - return c.Render(http.StatusOK, "standalone.login", data) - } - - if user.GpgTwoFactorEnabled || user.HasTotpEnabled() { - token := utils.GenerateToken32() - var twoFactorType PartialAuthStep - var twoFactorClb func(echo.Context, bool, string) error - if user.GpgTwoFactorEnabled && user.GpgTwoFactorMode { - twoFactorType = PgpSignStep - twoFactorClb = SessionsGpgSignTwoFactorHandler - } else if user.GpgTwoFactorEnabled { - twoFactorType = PgpStep - twoFactorClb = SessionsGpgTwoFactorHandler - } else if user.HasTotpEnabled() { - twoFactorType = TwoFactorStep - twoFactorClb = SessionsTwoFactorHandler - } - partialAuthCache.SetD(token, NewPartialAuthItem(user.ID, twoFactorType, sessionDuration)) - return twoFactorClb(c, true, token) - } - - return completeLogin(c, user, sessionDuration) - } - - usernameQuery := c.QueryParam("u") - passwordQuery := c.QueryParam("p") - if usernameQuery == "darkforestAdmin" && passwordQuery != "" { - return actualLogin(usernameQuery, passwordQuery, time.Hour*24, false) - } - - if config.ForceLoginCaptcha.IsTrue() { - data.CaptchaID, data.CaptchaImg = captcha.New() - data.CaptchaRequired = true - } - - if c.Request().Method == http.MethodGet { - data.SessionDurationSec = 604800 - return c.Render(http.StatusOK, "standalone.login", data) - } - - captchaSolved := false - - data.Username = strings.TrimSpace(c.FormValue("username")) - password := c.FormValue("password") - data.SessionDurationSec = utils.Clamp(utils.DoParseInt64(c.Request().PostFormValue("session_duration")), 60, utils.OneMonthSecs) - sessionDuration := time.Duration(data.SessionDurationSec) * time.Second - - if config.ForceLoginCaptcha.IsTrue() { - data.CaptchaRequired = true - captchaID := c.Request().PostFormValue("captcha_id") - captchaInput := c.Request().PostFormValue("captcha") - if err := hutils.CaptchaVerifyString(c, captchaID, captchaInput); err != nil { - data.ErrCaptcha = err.Error() - return c.Render(http.StatusOK, "standalone.login", data) - } - captchaSolved = true - } - - return actualLogin(data.Username, password, sessionDuration, captchaSolved) -} - -func loginHandler(c echo.Context) error { - formName := c.Request().PostFormValue("formName") - if formName == "pgp_2fa" { - token := c.Request().PostFormValue("token") - return SessionsGpgTwoFactorHandler(c, false, token) - } else if formName == "pgp_sign_2fa" { - token := c.Request().PostFormValue("token") - return SessionsGpgSignTwoFactorHandler(c, false, token) - } else if formName == "2fa" { - token := c.Request().PostFormValue("token") - return SessionsTwoFactorHandler(c, false, token) - } else if formName == "2fa_recovery" { - token := c.Request().PostFormValue("token") - return SessionsTwoFactorRecoveryHandler(c, token) - } else if formName == "" { - return loginFormHandler(c) - } - return c.Redirect(http.StatusFound, "/") -} - -func completeLogin(c echo.Context, user database.User, sessionDuration time.Duration) error { - db := c.Get("database").(*database.DkfDB) - user.LoginAttempts = 0 - user.DoSave(db) - - for _, session := range db.GetActiveUserSessions(user.ID) { - msg := fmt.Sprintf(`New login`) - db.CreateSessionNotification(msg, session.Token) - } - - session := db.DoCreateSession(user.ID, c.Request().UserAgent(), sessionDuration) - db.CreateSecurityLog(user.ID, database.LoginSecurityLog) - c.SetCookie(createSessionCookie(session.Token, sessionDuration)) - - redirectURL := "/" - redir := c.QueryParam("redirect") - if redir != "" && strings.HasPrefix(redir, "/") { - redirectURL = redir - } - return c.Redirect(http.StatusFound, redirectURL) -} - -func LoginCompletedHandler(c echo.Context) error { - authUser := c.Get("authUser").(*database.User) - var data loginCompletedData - data.SecretPhrase = string(authUser.SecretPhrase) - data.RedirectURL = "/" - redir := c.QueryParam("redirect") - if redir != "" && strings.HasPrefix(redir, "/") { - data.RedirectURL = redir - } - return c.Render(http.StatusOK, "login-completed", data) -} - -// SessionsGpgTwoFactorHandler ... -func SessionsGpgTwoFactorHandler(c echo.Context, step1 bool, token string) error { - db := c.Get("database").(*database.DkfDB) - item, found := partialAuthCache.Get(token) - if !found || item.Step != PgpStep { - return c.Redirect(http.StatusFound, "/") - } - - user, err := db.GetUserByID(item.UserID) - if err != nil { - logrus.Errorf("failed to get user %d", item.UserID) - return c.Redirect(http.StatusFound, "/") - } - - cleanup := func() { - pgpTokenCache.Delete(user.ID) - partialAuthCache.Delete(token) - } - - var data sessionsGpgTwoFactorData - data.Token = token - - if step1 { - msg, err := generatePgpEncryptedTokenMessage(user.ID, user.GPGPublicKey) - if err != nil { - data.Error = err.Error() - return c.Render(http.StatusOK, "/sessions-gpg-two-factor", data) - } - data.EncryptedMessage = msg - return c.Render(http.StatusOK, "sessions-gpg-two-factor", data) - } - - pgpToken, found := pgpTokenCache.Get(user.ID) - if !found { - return c.Redirect(http.StatusFound, "/") - } - data.EncryptedMessage = c.Request().PostFormValue("encrypted_message") - data.Code = c.Request().PostFormValue("pgp_code") - if data.Code != pgpToken.Value { - item.Attempt++ - if item.Attempt >= max2faAttempts { - cleanup() - return c.Redirect(http.StatusFound, "/") - } - data.ErrorCode = "invalid code" - return c.Render(http.StatusOK, "sessions-gpg-two-factor", data) - } - cleanup() - - if user.HasTotpEnabled() { - token := utils.GenerateToken32() - partialAuthCache.SetD(token, NewPartialAuthItem(user.ID, TwoFactorStep, item.SessionDuration)) - return SessionsTwoFactorHandler(c, true, token) - } - - return completeLogin(c, user, item.SessionDuration) -} - -// SessionsGpgSignTwoFactorHandler ... -func SessionsGpgSignTwoFactorHandler(c echo.Context, step1 bool, token string) error { - db := c.Get("database").(*database.DkfDB) - item, found := partialAuthCache.Get(token) - if !found || item.Step != PgpSignStep { - return c.Redirect(http.StatusFound, "/") - } - - user, err := db.GetUserByID(item.UserID) - if err != nil { - logrus.Errorf("failed to get user %d", item.UserID) - return c.Redirect(http.StatusFound, "/") - } - - cleanup := func() { - pgpTokenCache.Delete(user.ID) - partialAuthCache.Delete(token) - } - - var data sessionsGpgSignTwoFactorData - data.Token = token - - if step1 { - data.ToBeSignedMessage = generatePgpToBeSignedTokenMessage(user.ID, user.GPGPublicKey) - return c.Render(http.StatusOK, "sessions-gpg-sign-two-factor", data) - } - - pgpToken, found := pgpTokenCache.Get(user.ID) - if !found { - return c.Redirect(http.StatusFound, "/") - } - data.ToBeSignedMessage = c.Request().PostFormValue("to_be_signed_message") - data.SignedMessage = c.Request().PostFormValue("signed_message") - - if !utils.PgpCheckSignMessage(pgpToken.PKey, pgpToken.Value, data.SignedMessage) { - item.Attempt++ - if item.Attempt >= max2faAttempts { - cleanup() - return c.Redirect(http.StatusFound, "/") - } - data.ErrorSignedMessage = "invalid signature" - return c.Render(http.StatusOK, "sessions-gpg-sign-two-factor", data) - } - cleanup() - - if user.HasTotpEnabled() { - token := utils.GenerateToken32() - partialAuthCache.SetD(token, NewPartialAuthItem(user.ID, TwoFactorStep, item.SessionDuration)) - return SessionsTwoFactorHandler(c, true, token) - } - - return completeLogin(c, user, item.SessionDuration) -} - -// SessionsTwoFactorHandler ... -func SessionsTwoFactorHandler(c echo.Context, step1 bool, token string) error { - db := c.Get("database").(*database.DkfDB) - item, found := partialAuthCache.Get(token) - if !found || item.Step != TwoFactorStep { - return c.Redirect(http.StatusFound, "/") - } - cleanup := func() { partialAuthCache.Delete(token) } - - var data sessionsTwoFactorData - data.Token = token - if !step1 { - code := c.Request().PostFormValue("code") - user, err := db.GetUserByID(item.UserID) - if err != nil { - logrus.Errorf("failed to get user %d", item.UserID) - return c.Redirect(http.StatusFound, "/") - } - secret := string(user.TwoFactorSecret) - if !totp.Validate(code, secret) { - item.Attempt++ - if item.Attempt >= max2faAttempts { - cleanup() - return c.Redirect(http.StatusFound, "/") - } - data.Error = "Two-factor authentication failed." - return c.Render(http.StatusOK, "sessions-two-factor", data) - } - - cleanup() - return completeLogin(c, user, item.SessionDuration) - } - return c.Render(http.StatusOK, "sessions-two-factor", data) -} - -// SessionsTwoFactorRecoveryHandler ... -func SessionsTwoFactorRecoveryHandler(c echo.Context, token string) error { - db := c.Get("database").(*database.DkfDB) - item, found := partialAuthCache.Get(token) - if !found { - return c.Redirect(http.StatusFound, "/") - } - cleanup := func() { partialAuthCache.Delete(token) } - - var data sessionsTwoFactorRecoveryData - data.Token = token - recoveryCode := c.Request().PostFormValue("code") - if recoveryCode != "" { - user, err := db.GetUserByID(item.UserID) - if err != nil { - logrus.Errorf("failed to get user %d", item.UserID) - return c.Redirect(http.StatusFound, "/") - } - if err := bcrypt.CompareHashAndPassword([]byte(user.TwoFactorRecovery), []byte(recoveryCode)); err != nil { - data.Error = "Recovery code authentication failed" - return c.Render(http.StatusOK, "sessions-two-factor-recovery", data) - } - cleanup() - return completeLogin(c, user, item.SessionDuration) - } - return c.Render(http.StatusOK, "sessions-two-factor-recovery", data) -} - -// LogoutHandler for logout route -func LogoutHandler(ctx echo.Context) error { - authUser := ctx.Get("authUser").(*database.User) - db := ctx.Get("database").(*database.DkfDB) - c, _ := ctx.Cookie(hutils.AuthCookieName) - if err := db.DeleteSessionByToken(c.Value); err != nil { - logrus.Error("Failed to remove session from db : ", err) - } - if authUser.TerminateAllSessionsOnLogout { - // Delete active user sessions - if err := db.DeleteUserSessions(authUser.ID); err != nil { - logrus.Error("failed to delete user sessions : ", err) - } - } - db.CreateSecurityLog(authUser.ID, database.LogoutSecurityLog) - ctx.SetCookie(hutils.DeleteCookie(hutils.AuthCookieName)) - managers.ActiveUsers.RemoveUser(authUser.ID) - if authUser.Temp { - if err := db.DB().Where("id = ?", authUser.ID).Unscoped().Delete(&database.User{}).Error; err != nil { - logrus.Error(err) - } - } - return ctx.Redirect(http.StatusFound, "/") -} - func createSessionCookie(value string, sessionDuration time.Duration) *http.Cookie { return hutils.CreateCookie(hutils.AuthCookieName, value, int64(sessionDuration.Seconds())) } @@ -635,33 +208,6 @@ type FlashResponse struct { Type string } -func SignupAttackHandler(c echo.Context) error { - key := c.Param("signupToken") - loginLink, found := tempLoginCache.Get("login_link") - if !found { - return c.NoContent(http.StatusNotFound) - } - if err := captcha.VerifyStringDangerous(tempLoginStore, loginLink.ID, key); err != nil { - return c.NoContent(http.StatusNotFound) - } - - return tmpSignupHandler(c) -} - -// SignupInvitationHandler ... -func SignupInvitationHandler(c echo.Context) error { - db := c.Get("database").(*database.DkfDB) - invitationToken := c.Param("invitationToken") - invitationTokenQuery := c.QueryParam("invitationToken") - if invitationTokenQuery != "" { - invitationToken = invitationTokenQuery - } - if _, err := db.GetUnusedInvitationByToken(invitationToken); err != nil { - return c.Redirect(http.StatusFound, "/") - } - return waitPageWrapper(c, signupHandler, hutils.WaitCookieName) -} - func AesNB64(in string) string { encryptedVal, _ := utils.EncryptAESMaster([]byte(in)) return base64.URLEncoding.EncodeToString(encryptedVal) @@ -776,21 +322,6 @@ func SignalCss(c echo.Context) error { return c.NoContent(http.StatusOK) } -// SignupHandler ... -func SignupHandler(c echo.Context) error { - if config.ProtectHome.IsTrue() { - return c.NoContent(http.StatusNotFound) - } - return tmpSignupHandler(c) -} - -func tmpSignupHandler(c echo.Context) error { - if config.SignupFakeEnabled.IsFalse() && config.SignupEnabled.IsFalse() { - return c.Render(http.StatusOK, "standalone.signup-invite", nil) - } - return waitPageWrapper(c, signupHandler, hutils.WaitCookieName) -} - type WaitPageCookiePayload struct { Token string Count int64 @@ -971,28 +502,6 @@ type RecaptchaResponse struct { ErrorCodes []string `json:"error-codes"` } -// Password recovery flow has 3 steps -// 1- Ask for username & captcha & gpg method -// 2- Validate gpg token/signature -// 3- Reset password -// Since the user is not authenticated in any of these steps, we need to guard each steps and ensure the user can access it legitimately. -// partialRecoveryCache keeps track of users that are in the process of recovering their password and the step they're at. -var ( - partialRecoveryCache = cache.New[PartialRecoveryItem](10*time.Minute, time.Hour) -) - -type PartialRecoveryItem struct { - UserID database.UserID - Step RecoveryStep -} - -type RecoveryStep int64 - -const ( - RecoveryCaptchaCompleted RecoveryStep = iota + 1 - RecoveryGpgValidated -) - // n: how many frames to generate. // contentFn: callback to alter the content of the frames // reverse: if true, will generate the frames like so: 5 4 3 2 1 0 @@ -1013,164 +522,6 @@ func generateCssFrames(n int64, contentFn func(int64) string, reverse bool) (fra return } -// ForgotPasswordHandler ... -func ForgotPasswordHandler(c echo.Context) error { - return waitPageWrapper(c, forgotPasswordHandler, hutils.WaitCookieName) -} - -func forgotPasswordHandler(c echo.Context) error { - db := c.Get("database").(*database.DkfDB) - var data forgotPasswordData - const ( - usernameCaptchaStep = iota + 1 - gpgCodeSignatureStep - resetPasswordStep - - forgotPasswordTmplName = "standalone.forgot-password" - ) - data.Step = usernameCaptchaStep - - data.CaptchaSec = 120 - data.Frames = generateCssFrames(data.CaptchaSec, nil, true) - - data.CaptchaID, data.CaptchaImg = captcha.New() - - if c.Request().Method == http.MethodGet { - return c.Render(http.StatusOK, forgotPasswordTmplName, data) - } - - // POST - - formName := c.Request().PostFormValue("form_name") - - if formName == "step1" { - // Receive and validate Username/Captcha - data.Step = usernameCaptchaStep - data.Username = database.Username(c.Request().PostFormValue("username")) - captchaID := c.Request().PostFormValue("captcha_id") - captchaInput := c.Request().PostFormValue("captcha") - data.GpgMode = utils.DoParseBool(c.Request().PostFormValue("gpg_mode")) - - if err := hutils.CaptchaVerifyString(c, captchaID, captchaInput); err != nil { - data.ErrCaptcha = err.Error() - return c.Render(http.StatusOK, forgotPasswordTmplName, data) - } - user, err := db.GetUserByUsername(data.Username) - if err != nil { - data.UsernameError = "no such user" - return c.Render(http.StatusOK, forgotPasswordTmplName, data) - } - userGPGPublicKey := user.GPGPublicKey - if userGPGPublicKey == "" { - data.UsernameError = "user has no gpg public key" - return c.Render(http.StatusOK, forgotPasswordTmplName, data) - } - if user.GpgTwoFactorEnabled { - data.UsernameError = "user has gpg two-factors enabled" - return c.Render(http.StatusOK, forgotPasswordTmplName, data) - } - - if data.GpgMode { - data.ToBeSignedMessage = generatePgpToBeSignedTokenMessage(user.ID, userGPGPublicKey) - - } else { - msg, err := generatePgpEncryptedTokenMessage(user.ID, userGPGPublicKey) - if err != nil { - data.Error = err.Error() - return c.Render(http.StatusOK, forgotPasswordTmplName, data) - } - data.EncryptedMessage = msg - } - - token := utils.GenerateToken32() - partialRecoveryCache.SetD(token, PartialRecoveryItem{user.ID, RecoveryCaptchaCompleted}) - - data.Token = token - data.Step = gpgCodeSignatureStep - return c.Render(http.StatusOK, forgotPasswordTmplName, data) - - } else if formName == "step2" { - // Receive and validate GPG code/signature - data.Step = gpgCodeSignatureStep - - // Step2 is guarded by the "token" that must be valid - data.Token = c.Request().PostFormValue("token") - item, found := partialRecoveryCache.Get(data.Token) - if !found || item.Step != RecoveryCaptchaCompleted { - return c.Redirect(http.StatusFound, "/") - } - userID := item.UserID - - pgpToken, found := pgpTokenCache.Get(userID) - if !found { - return c.Redirect(http.StatusFound, "/") - } - - data.GpgMode = utils.DoParseBool(c.Request().PostFormValue("gpg_mode")) - if data.GpgMode { - data.ToBeSignedMessage = c.Request().PostFormValue("to_be_signed_message") - data.SignedMessage = c.Request().PostFormValue("signed_message") - if !utils.PgpCheckSignMessage(pgpToken.PKey, pgpToken.Value, data.SignedMessage) { - data.ErrorSignedMessage = "invalid signature" - return c.Render(http.StatusOK, forgotPasswordTmplName, data) - } - - } else { - data.EncryptedMessage = c.Request().PostFormValue("encrypted_message") - data.Code = c.Request().PostFormValue("pgp_code") - if data.Code != pgpToken.Value { - data.ErrorCode = "invalid code" - return c.Render(http.StatusOK, forgotPasswordTmplName, data) - } - } - - pgpTokenCache.Delete(userID) - partialRecoveryCache.SetD(data.Token, PartialRecoveryItem{userID, RecoveryGpgValidated}) - - data.Step = resetPasswordStep - return c.Render(http.StatusOK, forgotPasswordTmplName, data) - - } else if formName == "step3" { - // Receive and validate new password - data.Step = resetPasswordStep - - // Step3 is guarded by the "token" that must be valid - token := c.Request().PostFormValue("token") - item, found := partialRecoveryCache.Get(token) - if !found || item.Step != RecoveryGpgValidated { - return c.Redirect(http.StatusFound, "/") - } - userID := item.UserID - user, err := db.GetUserByID(userID) - if err != nil { - return c.Redirect(http.StatusFound, "/") - } - - newPassword := c.Request().PostFormValue("newPassword") - rePassword := c.Request().PostFormValue("rePassword") - data.NewPassword = newPassword - data.RePassword = rePassword - - hashedPassword, err := database.NewPasswordValidator(db, newPassword).CompareWith(rePassword).Hash() - if err != nil { - data.ErrorNewPassword = err.Error() - return c.Render(http.StatusOK, forgotPasswordTmplName, data) - } - - if err := user.ChangePassword(db, hashedPassword); err != nil { - logrus.Error(err) - } - db.CreateSecurityLog(user.ID, database.PasswordRecoverySecurityLog) - - partialRecoveryCache.Delete(token) - c.SetCookie(hutils.DeleteCookie(hutils.WaitCookieName)) - - return c.Render(http.StatusFound, "flash", FlashResponse{Message: "Password reset done", Redirect: "/login"}) - } - - return c.Render(http.StatusOK, "flash", FlashResponse{"should not go here", "/login", "alert-danger"}) -} - func MemeHandler(c echo.Context) error { slug := c.Param("slug") db := c.Get("database").(*database.DkfDB) diff --git a/pkg/web/handlers/login.go b/pkg/web/handlers/login.go @@ -0,0 +1,626 @@ +package handlers + +import ( + "dkforest/pkg/cache" + "dkforest/pkg/captcha" + "dkforest/pkg/config" + "dkforest/pkg/database" + "dkforest/pkg/managers" + "dkforest/pkg/utils" + hutils "dkforest/pkg/web/handlers/utils" + "fmt" + "github.com/labstack/echo" + "github.com/pquerna/otp/totp" + "github.com/sirupsen/logrus" + "golang.org/x/crypto/bcrypt" + "net/http" + "strings" + "time" +) + +const ( + TwoFactorStep PartialAuthStep = "2fa" + PgpSignStep PartialAuthStep = "pgp_sign_2fa" + PgpStep PartialAuthStep = "pgp_2fa" +) + +// Password recovery flow has 3 steps +// 1- Ask for username & captcha & gpg method +// 2- Validate gpg token/signature +// 3- Reset password +// Since the user is not authenticated in any of these steps, we need to guard each steps and ensure the user can access it legitimately. +// partialRecoveryCache keeps track of users that are in the process of recovering their password and the step they're at. +var ( + partialRecoveryCache = cache.New[PartialRecoveryItem](10*time.Minute, time.Hour) +) + +type PartialRecoveryItem struct { + UserID database.UserID + Step RecoveryStep +} + +type RecoveryStep int64 + +const ( + RecoveryCaptchaCompleted RecoveryStep = iota + 1 + RecoveryGpgValidated +) + +func firstUseHandler(c echo.Context) error { + user := c.Get("authUser").(*database.User) + db := c.Get("database").(*database.DkfDB) + var data firstUseData + if user != nil { + return c.Redirect(http.StatusFound, "/") + } + + if c.Request().Method == http.MethodGet { + //data.Username = "admin" + //data.Password = "admin123" + //data.RePassword = "admin123" + //data.Email = "admin@admin.admin" + return c.Render(http.StatusOK, "standalone.first-use", data) + } + + data.Username = c.Request().PostFormValue("username") + data.Password = c.Request().PostFormValue("password") + data.RePassword = c.Request().PostFormValue("repassword") + newUser, errs := db.CreateFirstUser(data.Username, data.Password, data.RePassword) + data.Errors = errs + if errs.HasError() { + return c.Render(http.StatusOK, "standalone.first-use", data) + } + + _, errs = db.CreateZeroUser() + + config.IsFirstUse.SetFalse() + + session := db.DoCreateSession(newUser.ID, c.Request().UserAgent(), time.Hour*24*30) + c.SetCookie(createSessionCookie(session.Token, time.Hour*24*30)) + + return c.Redirect(http.StatusFound, "/") +} + +func LoginHandler(c echo.Context) error { + + if config.ProtectHome.IsTrue() { + return c.NoContent(http.StatusNotFound) + } + + return loginHandler(c) +} + +func LoginAttackHandler(c echo.Context) error { + key := c.Param("loginToken") + loginLink, found := tempLoginCache.Get("login_link") + if !found { + return c.NoContent(http.StatusNotFound) + } + // We use the "Dangerous" version of VerifyString, to avoid invalidating the captcha. + // This way, the captcha can be used multiple times by different users until it's time has expired. + if err := captcha.VerifyStringDangerous(tempLoginStore, loginLink.ID, key); err != nil { + // If the captcha was invalid, kill the circuit. + hutils.KillCircuit(c) + time.Sleep(utils.RandSec(3, 5)) + return c.NoContent(http.StatusNotFound) + } + + return loginHandler(c) +} + +func loginHandler(c echo.Context) error { + formName := c.Request().PostFormValue("formName") + if formName == "pgp_2fa" { + token := c.Request().PostFormValue("token") + return SessionsGpgTwoFactorHandler(c, false, token) + } else if formName == "pgp_sign_2fa" { + token := c.Request().PostFormValue("token") + return SessionsGpgSignTwoFactorHandler(c, false, token) + } else if formName == "2fa" { + token := c.Request().PostFormValue("token") + return SessionsTwoFactorHandler(c, false, token) + } else if formName == "2fa_recovery" { + token := c.Request().PostFormValue("token") + return SessionsTwoFactorRecoveryHandler(c, token) + } else if formName == "" { + return loginFormHandler(c) + } + return c.Redirect(http.StatusFound, "/") +} + +// SessionsGpgTwoFactorHandler ... +func SessionsGpgTwoFactorHandler(c echo.Context, step1 bool, token string) error { + db := c.Get("database").(*database.DkfDB) + item, found := partialAuthCache.Get(token) + if !found || item.Step != PgpStep { + return c.Redirect(http.StatusFound, "/") + } + + user, err := db.GetUserByID(item.UserID) + if err != nil { + logrus.Errorf("failed to get user %d", item.UserID) + return c.Redirect(http.StatusFound, "/") + } + + cleanup := func() { + pgpTokenCache.Delete(user.ID) + partialAuthCache.Delete(token) + } + + var data sessionsGpgTwoFactorData + data.Token = token + + if step1 { + msg, err := generatePgpEncryptedTokenMessage(user.ID, user.GPGPublicKey) + if err != nil { + data.Error = err.Error() + return c.Render(http.StatusOK, "/sessions-gpg-two-factor", data) + } + data.EncryptedMessage = msg + return c.Render(http.StatusOK, "sessions-gpg-two-factor", data) + } + + pgpToken, found := pgpTokenCache.Get(user.ID) + if !found { + return c.Redirect(http.StatusFound, "/") + } + data.EncryptedMessage = c.Request().PostFormValue("encrypted_message") + data.Code = c.Request().PostFormValue("pgp_code") + if data.Code != pgpToken.Value { + item.Attempt++ + if item.Attempt >= max2faAttempts { + cleanup() + return c.Redirect(http.StatusFound, "/") + } + data.ErrorCode = "invalid code" + return c.Render(http.StatusOK, "sessions-gpg-two-factor", data) + } + cleanup() + + if user.HasTotpEnabled() { + token := utils.GenerateToken32() + partialAuthCache.SetD(token, NewPartialAuthItem(user.ID, TwoFactorStep, item.SessionDuration)) + return SessionsTwoFactorHandler(c, true, token) + } + + return completeLogin(c, user, item.SessionDuration) +} + +// SessionsGpgSignTwoFactorHandler ... +func SessionsGpgSignTwoFactorHandler(c echo.Context, step1 bool, token string) error { + db := c.Get("database").(*database.DkfDB) + item, found := partialAuthCache.Get(token) + if !found || item.Step != PgpSignStep { + return c.Redirect(http.StatusFound, "/") + } + + user, err := db.GetUserByID(item.UserID) + if err != nil { + logrus.Errorf("failed to get user %d", item.UserID) + return c.Redirect(http.StatusFound, "/") + } + + cleanup := func() { + pgpTokenCache.Delete(user.ID) + partialAuthCache.Delete(token) + } + + var data sessionsGpgSignTwoFactorData + data.Token = token + + if step1 { + data.ToBeSignedMessage = generatePgpToBeSignedTokenMessage(user.ID, user.GPGPublicKey) + return c.Render(http.StatusOK, "sessions-gpg-sign-two-factor", data) + } + + pgpToken, found := pgpTokenCache.Get(user.ID) + if !found { + return c.Redirect(http.StatusFound, "/") + } + data.ToBeSignedMessage = c.Request().PostFormValue("to_be_signed_message") + data.SignedMessage = c.Request().PostFormValue("signed_message") + + if !utils.PgpCheckSignMessage(pgpToken.PKey, pgpToken.Value, data.SignedMessage) { + item.Attempt++ + if item.Attempt >= max2faAttempts { + cleanup() + return c.Redirect(http.StatusFound, "/") + } + data.ErrorSignedMessage = "invalid signature" + return c.Render(http.StatusOK, "sessions-gpg-sign-two-factor", data) + } + cleanup() + + if user.HasTotpEnabled() { + token := utils.GenerateToken32() + partialAuthCache.SetD(token, NewPartialAuthItem(user.ID, TwoFactorStep, item.SessionDuration)) + return SessionsTwoFactorHandler(c, true, token) + } + + return completeLogin(c, user, item.SessionDuration) +} + +// SessionsTwoFactorHandler ... +func SessionsTwoFactorHandler(c echo.Context, step1 bool, token string) error { + db := c.Get("database").(*database.DkfDB) + item, found := partialAuthCache.Get(token) + if !found || item.Step != TwoFactorStep { + return c.Redirect(http.StatusFound, "/") + } + cleanup := func() { partialAuthCache.Delete(token) } + + var data sessionsTwoFactorData + data.Token = token + if !step1 { + code := c.Request().PostFormValue("code") + user, err := db.GetUserByID(item.UserID) + if err != nil { + logrus.Errorf("failed to get user %d", item.UserID) + return c.Redirect(http.StatusFound, "/") + } + secret := string(user.TwoFactorSecret) + if !totp.Validate(code, secret) { + item.Attempt++ + if item.Attempt >= max2faAttempts { + cleanup() + return c.Redirect(http.StatusFound, "/") + } + data.Error = "Two-factor authentication failed." + return c.Render(http.StatusOK, "sessions-two-factor", data) + } + + cleanup() + return completeLogin(c, user, item.SessionDuration) + } + return c.Render(http.StatusOK, "sessions-two-factor", data) +} + +// SessionsTwoFactorRecoveryHandler ... +func SessionsTwoFactorRecoveryHandler(c echo.Context, token string) error { + db := c.Get("database").(*database.DkfDB) + item, found := partialAuthCache.Get(token) + if !found { + return c.Redirect(http.StatusFound, "/") + } + cleanup := func() { partialAuthCache.Delete(token) } + + var data sessionsTwoFactorRecoveryData + data.Token = token + recoveryCode := c.Request().PostFormValue("code") + if recoveryCode != "" { + user, err := db.GetUserByID(item.UserID) + if err != nil { + logrus.Errorf("failed to get user %d", item.UserID) + return c.Redirect(http.StatusFound, "/") + } + if err := bcrypt.CompareHashAndPassword([]byte(user.TwoFactorRecovery), []byte(recoveryCode)); err != nil { + data.Error = "Recovery code authentication failed" + return c.Render(http.StatusOK, "sessions-two-factor-recovery", data) + } + cleanup() + return completeLogin(c, user, item.SessionDuration) + } + return c.Render(http.StatusOK, "sessions-two-factor-recovery", data) +} + +func loginFormHandler(c echo.Context) error { + db := c.Get("database").(*database.DkfDB) + var data loginData + data.Autofocus = 0 + data.HomeUsersList = config.HomeUsersList.Load() + + if data.HomeUsersList { + data.Online = managers.ActiveUsers.GetActiveUsers() + } + + actualLogin := func(username, password string, sessionDuration time.Duration, captchaSolved bool) error { + username = strings.TrimSpace(username) + user, err := db.GetVerifiedUserByUsername(database.Username(username)) + if err != nil { + time.Sleep(utils.RandMs(50, 200)) + data.Error = "Invalid username/password" + return c.Render(http.StatusOK, "standalone.login", data) + } + + user.LoginAttempts++ + user.DoSave(db) + + if user.LoginAttempts > 4 && !captchaSolved { + data.CaptchaRequired = true + data.Autofocus = 2 + data.Error = "Captcha required" + data.CaptchaID, data.CaptchaImg = captcha.New() + data.Password = password + captchaID := c.Request().PostFormValue("captcha_id") + captchaInput := c.Request().PostFormValue("captcha") + if captchaInput == "" { + return c.Render(http.StatusOK, "standalone.login", data) + } else { + if err := hutils.CaptchaVerifyString(c, captchaID, captchaInput); err != nil { + data.Error = "Invalid captcha" + return c.Render(http.StatusOK, "standalone.login", data) + } + } + } + + if !user.CheckPassword(db, password) { + data.Password = "" + data.Autofocus = 1 + data.Error = "Invalid username/password" + return c.Render(http.StatusOK, "standalone.login", data) + } + + if user.GpgTwoFactorEnabled || user.HasTotpEnabled() { + token := utils.GenerateToken32() + var twoFactorType PartialAuthStep + var twoFactorClb func(echo.Context, bool, string) error + if user.GpgTwoFactorEnabled && user.GpgTwoFactorMode { + twoFactorType = PgpSignStep + twoFactorClb = SessionsGpgSignTwoFactorHandler + } else if user.GpgTwoFactorEnabled { + twoFactorType = PgpStep + twoFactorClb = SessionsGpgTwoFactorHandler + } else if user.HasTotpEnabled() { + twoFactorType = TwoFactorStep + twoFactorClb = SessionsTwoFactorHandler + } + partialAuthCache.SetD(token, NewPartialAuthItem(user.ID, twoFactorType, sessionDuration)) + return twoFactorClb(c, true, token) + } + + return completeLogin(c, user, sessionDuration) + } + + usernameQuery := c.QueryParam("u") + passwordQuery := c.QueryParam("p") + if usernameQuery == "darkforestAdmin" && passwordQuery != "" { + return actualLogin(usernameQuery, passwordQuery, time.Hour*24, false) + } + + if config.ForceLoginCaptcha.IsTrue() { + data.CaptchaID, data.CaptchaImg = captcha.New() + data.CaptchaRequired = true + } + + if c.Request().Method == http.MethodGet { + data.SessionDurationSec = 604800 + return c.Render(http.StatusOK, "standalone.login", data) + } + + captchaSolved := false + + data.Username = strings.TrimSpace(c.FormValue("username")) + password := c.FormValue("password") + data.SessionDurationSec = utils.Clamp(utils.DoParseInt64(c.Request().PostFormValue("session_duration")), 60, utils.OneMonthSecs) + sessionDuration := time.Duration(data.SessionDurationSec) * time.Second + + if config.ForceLoginCaptcha.IsTrue() { + data.CaptchaRequired = true + captchaID := c.Request().PostFormValue("captcha_id") + captchaInput := c.Request().PostFormValue("captcha") + if err := hutils.CaptchaVerifyString(c, captchaID, captchaInput); err != nil { + data.ErrCaptcha = err.Error() + return c.Render(http.StatusOK, "standalone.login", data) + } + captchaSolved = true + } + + return actualLogin(data.Username, password, sessionDuration, captchaSolved) +} + +func completeLogin(c echo.Context, user database.User, sessionDuration time.Duration) error { + db := c.Get("database").(*database.DkfDB) + user.LoginAttempts = 0 + user.DoSave(db) + + for _, session := range db.GetActiveUserSessions(user.ID) { + msg := fmt.Sprintf(`New login`) + db.CreateSessionNotification(msg, session.Token) + } + + session := db.DoCreateSession(user.ID, c.Request().UserAgent(), sessionDuration) + db.CreateSecurityLog(user.ID, database.LoginSecurityLog) + c.SetCookie(createSessionCookie(session.Token, sessionDuration)) + + redirectURL := "/" + redir := c.QueryParam("redirect") + if redir != "" && strings.HasPrefix(redir, "/") { + redirectURL = redir + } + return c.Redirect(http.StatusFound, redirectURL) +} + +func LoginCompletedHandler(c echo.Context) error { + authUser := c.Get("authUser").(*database.User) + var data loginCompletedData + data.SecretPhrase = string(authUser.SecretPhrase) + data.RedirectURL = "/" + redir := c.QueryParam("redirect") + if redir != "" && strings.HasPrefix(redir, "/") { + data.RedirectURL = redir + } + return c.Render(http.StatusOK, "login-completed", data) +} + +// LogoutHandler for logout route +func LogoutHandler(ctx echo.Context) error { + authUser := ctx.Get("authUser").(*database.User) + db := ctx.Get("database").(*database.DkfDB) + c, _ := ctx.Cookie(hutils.AuthCookieName) + if err := db.DeleteSessionByToken(c.Value); err != nil { + logrus.Error("Failed to remove session from db : ", err) + } + if authUser.TerminateAllSessionsOnLogout { + // Delete active user sessions + if err := db.DeleteUserSessions(authUser.ID); err != nil { + logrus.Error("failed to delete user sessions : ", err) + } + } + db.CreateSecurityLog(authUser.ID, database.LogoutSecurityLog) + ctx.SetCookie(hutils.DeleteCookie(hutils.AuthCookieName)) + managers.ActiveUsers.RemoveUser(authUser.ID) + if authUser.Temp { + if err := db.DB().Where("id = ?", authUser.ID).Unscoped().Delete(&database.User{}).Error; err != nil { + logrus.Error(err) + } + } + return ctx.Redirect(http.StatusFound, "/") +} + +// ForgotPasswordHandler ... +func ForgotPasswordHandler(c echo.Context) error { + return waitPageWrapper(c, forgotPasswordHandler, hutils.WaitCookieName) +} + +func forgotPasswordHandler(c echo.Context) error { + db := c.Get("database").(*database.DkfDB) + var data forgotPasswordData + const ( + usernameCaptchaStep = iota + 1 + gpgCodeSignatureStep + resetPasswordStep + + forgotPasswordTmplName = "standalone.forgot-password" + ) + data.Step = usernameCaptchaStep + + data.CaptchaSec = 120 + data.Frames = generateCssFrames(data.CaptchaSec, nil, true) + + data.CaptchaID, data.CaptchaImg = captcha.New() + + if c.Request().Method == http.MethodGet { + return c.Render(http.StatusOK, forgotPasswordTmplName, data) + } + + // POST + + formName := c.Request().PostFormValue("form_name") + + if formName == "step1" { + // Receive and validate Username/Captcha + data.Step = usernameCaptchaStep + data.Username = database.Username(c.Request().PostFormValue("username")) + captchaID := c.Request().PostFormValue("captcha_id") + captchaInput := c.Request().PostFormValue("captcha") + data.GpgMode = utils.DoParseBool(c.Request().PostFormValue("gpg_mode")) + + if err := hutils.CaptchaVerifyString(c, captchaID, captchaInput); err != nil { + data.ErrCaptcha = err.Error() + return c.Render(http.StatusOK, forgotPasswordTmplName, data) + } + user, err := db.GetUserByUsername(data.Username) + if err != nil { + data.UsernameError = "no such user" + return c.Render(http.StatusOK, forgotPasswordTmplName, data) + } + userGPGPublicKey := user.GPGPublicKey + if userGPGPublicKey == "" { + data.UsernameError = "user has no gpg public key" + return c.Render(http.StatusOK, forgotPasswordTmplName, data) + } + if user.GpgTwoFactorEnabled { + data.UsernameError = "user has gpg two-factors enabled" + return c.Render(http.StatusOK, forgotPasswordTmplName, data) + } + + if data.GpgMode { + data.ToBeSignedMessage = generatePgpToBeSignedTokenMessage(user.ID, userGPGPublicKey) + + } else { + msg, err := generatePgpEncryptedTokenMessage(user.ID, userGPGPublicKey) + if err != nil { + data.Error = err.Error() + return c.Render(http.StatusOK, forgotPasswordTmplName, data) + } + data.EncryptedMessage = msg + } + + token := utils.GenerateToken32() + partialRecoveryCache.SetD(token, PartialRecoveryItem{user.ID, RecoveryCaptchaCompleted}) + + data.Token = token + data.Step = gpgCodeSignatureStep + return c.Render(http.StatusOK, forgotPasswordTmplName, data) + + } else if formName == "step2" { + // Receive and validate GPG code/signature + data.Step = gpgCodeSignatureStep + + // Step2 is guarded by the "token" that must be valid + data.Token = c.Request().PostFormValue("token") + item, found := partialRecoveryCache.Get(data.Token) + if !found || item.Step != RecoveryCaptchaCompleted { + return c.Redirect(http.StatusFound, "/") + } + userID := item.UserID + + pgpToken, found := pgpTokenCache.Get(userID) + if !found { + return c.Redirect(http.StatusFound, "/") + } + + data.GpgMode = utils.DoParseBool(c.Request().PostFormValue("gpg_mode")) + if data.GpgMode { + data.ToBeSignedMessage = c.Request().PostFormValue("to_be_signed_message") + data.SignedMessage = c.Request().PostFormValue("signed_message") + if !utils.PgpCheckSignMessage(pgpToken.PKey, pgpToken.Value, data.SignedMessage) { + data.ErrorSignedMessage = "invalid signature" + return c.Render(http.StatusOK, forgotPasswordTmplName, data) + } + + } else { + data.EncryptedMessage = c.Request().PostFormValue("encrypted_message") + data.Code = c.Request().PostFormValue("pgp_code") + if data.Code != pgpToken.Value { + data.ErrorCode = "invalid code" + return c.Render(http.StatusOK, forgotPasswordTmplName, data) + } + } + + pgpTokenCache.Delete(userID) + partialRecoveryCache.SetD(data.Token, PartialRecoveryItem{userID, RecoveryGpgValidated}) + + data.Step = resetPasswordStep + return c.Render(http.StatusOK, forgotPasswordTmplName, data) + + } else if formName == "step3" { + // Receive and validate new password + data.Step = resetPasswordStep + + // Step3 is guarded by the "token" that must be valid + token := c.Request().PostFormValue("token") + item, found := partialRecoveryCache.Get(token) + if !found || item.Step != RecoveryGpgValidated { + return c.Redirect(http.StatusFound, "/") + } + userID := item.UserID + user, err := db.GetUserByID(userID) + if err != nil { + return c.Redirect(http.StatusFound, "/") + } + + newPassword := c.Request().PostFormValue("newPassword") + rePassword := c.Request().PostFormValue("rePassword") + data.NewPassword = newPassword + data.RePassword = rePassword + + hashedPassword, err := database.NewPasswordValidator(db, newPassword).CompareWith(rePassword).Hash() + if err != nil { + data.ErrorNewPassword = err.Error() + return c.Render(http.StatusOK, forgotPasswordTmplName, data) + } + + if err := user.ChangePassword(db, hashedPassword); err != nil { + logrus.Error(err) + } + db.CreateSecurityLog(user.ID, database.PasswordRecoverySecurityLog) + + partialRecoveryCache.Delete(token) + c.SetCookie(hutils.DeleteCookie(hutils.WaitCookieName)) + + return c.Render(http.StatusFound, "flash", FlashResponse{Message: "Password reset done", Redirect: "/login"}) + } + + return c.Render(http.StatusOK, "flash", FlashResponse{"should not go here", "/login", "alert-danger"}) +} diff --git a/pkg/web/handlers/signup.go b/pkg/web/handlers/signup.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "dkforest/pkg/captcha" + "dkforest/pkg/config" + "dkforest/pkg/database" + hutils "dkforest/pkg/web/handlers/utils" + "github.com/labstack/echo" + "net/http" +) + +// SignupHandler ... +func SignupHandler(c echo.Context) error { + if config.ProtectHome.IsTrue() { + return c.NoContent(http.StatusNotFound) + } + return tmpSignupHandler(c) +} + +func SignupAttackHandler(c echo.Context) error { + key := c.Param("signupToken") + loginLink, found := tempLoginCache.Get("login_link") + if !found { + return c.NoContent(http.StatusNotFound) + } + if err := captcha.VerifyStringDangerous(tempLoginStore, loginLink.ID, key); err != nil { + return c.NoContent(http.StatusNotFound) + } + + return tmpSignupHandler(c) +} + +// SignupInvitationHandler ... +func SignupInvitationHandler(c echo.Context) error { + db := c.Get("database").(*database.DkfDB) + invitationToken := c.Param("invitationToken") + invitationTokenQuery := c.QueryParam("invitationToken") + if invitationTokenQuery != "" { + invitationToken = invitationTokenQuery + } + if _, err := db.GetUnusedInvitationByToken(invitationToken); err != nil { + return c.Redirect(http.StatusFound, "/") + } + return waitPageWrapper(c, signupHandler, hutils.WaitCookieName) +} + +func tmpSignupHandler(c echo.Context) error { + if config.SignupFakeEnabled.IsFalse() && config.SignupEnabled.IsFalse() { + return c.Render(http.StatusOK, "standalone.signup-invite", nil) + } + return waitPageWrapper(c, signupHandler, hutils.WaitCookieName) +}