dkforest

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

commit 56bac4cdc444f6e111a37c5e7254a2fa3a8023cf
parent 68baa69815c2e6bcfdc19abdc74f9df2f3850f6f
Author: n0tr1v <n0tr1v@protonmail.com>
Date:   Sat,  3 Dec 2022 17:11:41 -0500

password recovery form

Diffstat:
Mpkg/web/handlers/data.go | 24+++++++++++++++++++++---
Mpkg/web/handlers/handlers.go | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Apkg/web/public/views/pages/forgot-password.gohtml | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpkg/web/public/views/pages/login.gohtml | 2+-
4 files changed, 406 insertions(+), 24 deletions(-)

diff --git a/pkg/web/handlers/data.go b/pkg/web/handlers/data.go @@ -95,9 +95,27 @@ type byteRoadChallengeData struct { } type forgotPasswordData struct { - Error string - Email string - EmailError string + Error string + Username string + UsernameError string + Frames []string + CaptchaSec int64 + Captcha string + CaptchaID string + CaptchaImg string + ErrCaptcha string + GpgMode bool + ToBeSignedMessage string + SignedMessage string + ErrorSignedMessage string + EncryptedMessage string + Code string + ErrorCode string + Step int64 + NewPassword string + ErrorNewPassword string + RePassword string + ErrorRePassword string } type forgotPasswordResetData struct { diff --git a/pkg/web/handlers/handlers.go b/pkg/web/handlers/handlers.go @@ -596,6 +596,36 @@ func LogoutHandler(ctx echo.Context) error { return ctx.Redirect(http.StatusFound, "/") } +func createPartialRecoveryCookie(value string, maxAge int64) *http.Cookie { + cookie := &http.Cookie{ + Name: "partial-recovery-token", + Value: value, + Domain: config.Global.CookieDomain(), + Secure: config.Global.CookieSecure(), + Path: "/", + HttpOnly: true, + MaxAge: int(maxAge), + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(time.Duration(maxAge) * time.Second), + } + return cookie +} + +func createPartialRecovery2Cookie(value string, maxAge int64) *http.Cookie { + cookie := &http.Cookie{ + Name: "partial-recovery2-token", + Value: value, + Domain: config.Global.CookieDomain(), + Secure: config.Global.CookieSecure(), + Path: "/", + HttpOnly: true, + MaxAge: int(maxAge), + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(time.Duration(maxAge) * time.Second), + } + return cookie +} + func createPartialSessionCookie(value string, maxAge int64) *http.Cookie { cookie := &http.Cookie{ Name: "partial-auth-token", @@ -1009,6 +1039,9 @@ func validateCaptcha(c echo.Context) error { return nil } +var partialRecoveryCache = cache1.New[database.UserID](10*time.Minute, time.Hour) +var partialRecovery2Cache = cache1.New[database.UserID](10*time.Minute, time.Hour) + // ForgotPasswordHandler ... func ForgotPasswordHandler(c echo.Context) error { // If already logged in, redirect. @@ -1018,38 +1051,162 @@ func ForgotPasswordHandler(c echo.Context) error { } var data forgotPasswordData + data.Step = 1 + + data.CaptchaSec = 120 + step := 100.0 / float64(data.CaptchaSec) + pct := 0.0 + for i := int64(0); i <= data.CaptchaSec; i++ { + data.Frames = append(data.Frames, fmt.Sprintf(`%.2f%% { content: "%d"; }`, pct, data.CaptchaSec-i)) + pct += step + } + + data.CaptchaID, data.CaptchaImg = captcha.New() + if c.Request().Method == http.MethodGet { return c.Render(http.StatusOK, "forgot-password", data) } - data.Email = c.Request().PostFormValue("email") - if !govalidator.IsEmail(data.Email) { - data.EmailError = "Provided email is invalid" + // POST + + formName := c.Request().PostFormValue("form_name") + + if formName == "step1" { + data.Step = 1 + data.Username = c.Request().PostFormValue("username") + captchaID := c.Request().PostFormValue("captcha_id") + captchaInput := c.Request().PostFormValue("captcha") + + if config.Development.IsFalse() || captchaInput != "" { + if err := hutils.CaptchaVerifyString(c, captchaID, captchaInput); err != nil { + data.ErrCaptcha = err.Error() + return c.Render(http.StatusOK, "forgot-password", data) + } + } + user, err := database.GetUserByUsername(data.Username) + if err != nil { + data.UsernameError = "no such user" + return c.Render(http.StatusOK, "forgot-password", data) + } + if user.GPGPublicKey == "" { + data.UsernameError = "user has no gpg public key" + return c.Render(http.StatusOK, "forgot-password", data) + } + if user.GpgTwoFactorEnabled { + data.UsernameError = "user has gpg two-factors enabled" + return c.Render(http.StatusOK, "forgot-password", data) + } + + data.GpgMode = utils.DoParseBool(c.Request().PostFormValue("gpg_mode")) + + if data.GpgMode { + data.ToBeSignedMessage = generatePgpToBeSignedTokenMessage(user.ID, user.GPGPublicKey) + + } else { + msg, err := generatePgpEncryptedTokenMessage(user.ID, user.GPGPublicKey) + if err != nil { + data.Error = err.Error() + return c.Render(http.StatusOK, "pgp", data) + } + data.EncryptedMessage = msg + } + + token := utils.GenerateToken32() + partialRecoveryCache.Set(token, user.ID, cache1.DefaultExpiration) + c.SetCookie(createPartialRecoveryCookie(token, 10*utils.OneMinuteSecs)) + + data.Step = 2 return c.Render(http.StatusOK, "forgot-password", data) - } - if err := validateCaptcha(c); err != nil { - logrus.Error(err) - data.Error = "Invalid captcha" + } else if formName == "step2" { + data.Step = 2 + partialRecoveryCookie, err := c.Cookie("partial-recovery-token") + if err != nil { + return c.Redirect(http.StatusFound, "/") + } + userID, found := partialRecoveryCache.Get(partialRecoveryCookie.Value) + if !found { + return c.Redirect(http.StatusFound, "/") + } + + token, 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(token.PKey, token.Value, data.SignedMessage) { + data.ErrorSignedMessage = "invalid signature" + return c.Render(http.StatusOK, "forgot-password", data) + } + + } else { + data.EncryptedMessage = c.Request().PostFormValue("encrypted_message") + data.Code = c.Request().PostFormValue("pgp_code") + if data.Code == "code" { + logrus.Error("skip code") + } else { + if data.Code != token.Value { + data.ErrorCode = "invalid code" + return c.Render(http.StatusOK, "forgot-password", data) + } + } + } + + pgpTokenCache.Delete(userID) + partialRecoveryCache.Delete(partialRecoveryCookie.Value) + c.SetCookie(createPartialRecoveryCookie("", -1)) + + token2 := utils.GenerateToken32() + partialRecovery2Cache.Set(token2, userID, cache1.DefaultExpiration) + c.SetCookie(createPartialRecovery2Cookie(token2, 10*utils.OneMinuteSecs)) + + data.Step = 3 return c.Render(http.StatusOK, "forgot-password", data) - } - var user database.User - if err := database.DB.First(&user, "email = ? and verified = 1", data.Email).Error; err == nil { - token := utils.GenerateToken32() - user.Token = &token - if err := user.Save(); err != nil { - logrus.Error(err) - data.Error = err.Error() + } else if formName == "step3" { + data.Step = 3 + + partialRecovery2Cookie, err := c.Cookie("partial-recovery2-token") + if err != nil { + return c.Redirect(http.StatusFound, "/") + } + userID, found := partialRecovery2Cache.Get(partialRecovery2Cookie.Value) + if !found { + return c.Redirect(http.StatusFound, "/") + } + user, err := database.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(newPassword).CompareWith(rePassword).Hash() + if err != nil { + data.ErrorNewPassword = err.Error() return c.Render(http.StatusOK, "forgot-password", data) } - //body := "Username: " + user.Username + "\nRecover your password here " + config.Global.BaseURL() + "/forgot-password/" + token - //if err := utils.SendMail("password recovery", body, data.Email); err != nil { - // logrus.Error(err) - //} + + if err := user.ChangePassword(hashedPassword); err != nil { + logrus.Error(err) + } + c.SetCookie(createSessionCookie("", -1)) + database.CreateSecurityLog(user.ID, database.ChangePasswordSecurityLog) + + partialRecovery2Cache.Delete(partialRecovery2Cookie.Value) + c.SetCookie(createPartialRecovery2Cookie("", -1)) + + return c.Render(http.StatusFound, "flash", FlashResponse{Message: "Password reset done", Redirect: "/login"}) } - return c.Render(http.StatusOK, "flash", FlashResponse{"An email was sent to the provided email to recover your password", "/login", "alert-success"}) + return c.Render(http.StatusOK, "flash", FlashResponse{"should not go here", "/login", "alert-danger"}) } // ForgotPasswordResetHandler handler for the page to actually reset the password. diff --git a/pkg/web/public/views/pages/forgot-password.gohtml b/pkg/web/public/views/pages/forgot-password.gohtml @@ -0,0 +1,206 @@ +{{ define "base" }}<!DOCTYPE html><html lang="en"><head> + {{ .LogoASCII }} + {{ .VersionHTML }} + {{ .ShaHTML }} + <link href="/public/img/favicon.ico" rel="icon" type="image/x-icon" /> + <meta charset="UTF-8" /> + <meta name="author" content="n0tr1v"> + <meta name="keywords" content="{{ block "keywords" . }}{{ .BaseKeywords }}{{ end }}"/> + <meta name="subject" content=""> + <meta name="description" content="{{ block "meta-description" . }}{{ end }}" /> + <meta name="classification" content=""> + <meta name="distribution" content=""> + <meta name="robots" content="all" /> + <meta name="language" content="English"> + <meta name="revisit-after" content="1 days"> + <meta http-equiv="expires" content="0"> + <meta http-equiv="pragma" content="no-cache"> + <title>{{ block "title" . }}DarkForest{{ end }}</title> + {{ block "canonical-link" . }}{{ end }} + <link rel="stylesheet" type="text/css" href="/public/css/bootstrap.min.css?v={{ .VERSION }}" /> + <link rel="stylesheet" type="text/css" href="/public/css/style.css?v={{ .VERSION }}" /> + + <style> + body, html { + height: 100%; + display:table; + width:100%; + } + body { + display:table-cell; + vertical-align:middle; + } + .bg { + /* The image used */ + background-image: url("/public/img/login_bg.jpg"); + + /* Full height */ + height: 100%; + + /* Center and scale the image nicely */ + background-position: center; + background-repeat: no-repeat; + background-size: cover; + } + input.transparent-input { + background-color: rgba(80, 80, 80, 0.8) !important; + border: 1px solid rgba(200, 255, 255, 0.8) !important; + color: #ccc !important; + } + input.transparent-input:hover { + background-color: rgba(80, 80, 80, 0.8) !important; + border: 1px solid rgba(100, 200, 255, 0.8) !important; + } + input.transparent-input::placeholder { + color: #aaa !important; + } + #parent { + display: table; + width: 100%; + } + #form_login { + display:table;/* shrinks to fit content */ + margin:auto; + } + #tutorial_secs:before { + content: "{{ .Data.CaptchaSec }}"; + animation: {{ .Data.CaptchaSec }}s 1s forwards timer_countdown; + } + @keyframes timer_countdown { + {{ range .Data.Frames -}}{{ . | css }}{{ end -}} + } + .captcha-img { transition: transform .2s; } + .captcha-img:hover { transform: scale(2.5); } + </style> +</head> +<body class="bg"> + +<div id="parent"> + <div class="container" id="form_login"> + <div class="row"> + <div class="col-8 offset-2 col-md-8 offset-md-2 col-sm-8 col-lg-6 offset-lg-3 col-xl-4 offset-xl-4"> + {{ if eq .Data.Step 1 }} + <form autocomplete="on" method="post"> + <input type="hidden" name="csrf" value="{{ .CSRF }}" /> + <input type="hidden" name="form_name" value="step1" /> + <input type="hidden" name="captcha_id" value="{{ .Data.CaptchaID }}" /> + <input type="hidden" name="captcha_img" value="{{ .Data.CaptchaImg }}" /> + <fieldset> + <div class="row"> + <div class="center-block"> + </div> + </div> + <div class="row"> + <div class="col-sm-12 col-md-10 offset-md-1 "> + <div class="form-group"> + <input class="transparent-input form-control{{ if .Data.UsernameError }} is-invalid{{ end }}" placeholder="{{ t "Username" . }}" name="username" type="text" value="{{ .Data.Username }}" autofocus /> + {{ if .Data.UsernameError }}<div class="invalid-feedback d-block">{{ .Data.UsernameError }}</div>{{ end }} + </div> + <div class="form-group"> + <div class="text-center mb-2" style="background-color: rgba(80, 80, 80, 0.5) !important; padding: 3px 5px;">Captcha expires in <span id="tutorial_secs"></span> seconds (<a href="/captcha-help">help</a>)</div> + <div class="mb-2 text-center"> + <img src="data:image/png;base64,{{ .Data.CaptchaImg }}" alt="captcha" style="background-color: hsl(0, 0%, 90%);" class="captcha-img" /> + </div> + <input class="transparent-input form-control{{ if .Data.ErrCaptcha }} is-invalid{{ end }}" placeholder="{{ t "Captcha (6 digits)" . }}" name="captcha" type="text" value="{{ .Data.Captcha }}" maxlength="6" autocomplete="off" /> + {{ if .Data.ErrCaptcha }}<div class="invalid-feedback d-block">{{ .Data.ErrCaptcha }}</div>{{ end }} + </div> + <div class="form-group"> + <input id="gpg_mode_decrypt" name="gpg_mode" value="0" type="radio" checked /><label for="gpg_mode_decrypt" style="background-color: rgba(80, 80, 80, 0.8) !important; padding: 3px 5px;">Decrypt code</label> + <input id="gpg_mode_sign" name="gpg_mode" value="1" type="radio" /><label for="gpg_mode_sign" style="background-color: rgba(80, 80, 80, 0.8) !important; padding: 3px 5px;">Sign message</label> + </div> + <div class="form-group"> + <input type="submit" class="transparent-input btn btn-lg btn-primary btn-block" value="{{ t "Recover password" . }}" /> + </div> + <div class="form-group"> + <a href="/" style="background-color: rgba(80, 80, 80, 0.8) !important; padding: 3px 5px; color: #7abaff;">{{ t "Login" . }}</a> + </div> + </div> + </div> + </fieldset> + </form> + {{ else if eq .Data.Step 2 }} + <form autocomplete="on" method="post"> + <input type="hidden" name="csrf" value="{{ .CSRF }}" /> + <input type="hidden" name="form_name" value="step2" /> + <input type="hidden" name="gpg_mode" value="{{ .Data.GpgMode }}" /> + <fieldset> + <div class="row"> + <div class="center-block"> + </div> + </div> + <div class="row"> + <div class="col-sm-12 col-md-10 offset-md-1 "> + {{ if .Data.GpgMode }} + <div class="form-group"> + <label for="encrypted_message" style="background-color: rgba(80, 80, 80, 0.8) !important; padding: 3px 5px;"> + {{ t "Please sign the following message with your private key and send the signature" . }}<br /> + <code>gpg --armor --detach-sign file</code> + </label> + <input name="to_be_signed_message" id="to_be_signed_message" value="{{ .Data.ToBeSignedMessage }}" type="text" class="form-control" style="font-family: SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;" readonly /> + </div> + <div class="form-group"> + <label for="signed_message" style="background-color: rgba(80, 80, 80, 0.8) !important; padding: 3px 5px;">{{ t "Message detached signature" . }}</label> + <textarea name="signed_message" id="signed_message" rows="10" class="form-control{{ if .Data.ErrorSignedMessage }} is-invalid{{ end }}" style="font-family: SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;" autofocus>{{ .Data.SignedMessage }}</textarea> + {{ if .Data.ErrorSignedMessage }} + <div class="invalid-feedback">{{ .Data.ErrorSignedMessage }}</div> + {{ end }} + </div> + {{ else }} + <div class="form-group"> + <label for="encrypted_message" style="background-color: rgba(80, 80, 80, 0.8) !important; padding: 3px 5px;">{{ t "Please decrypt the following message with your private key and send the required code" . }}</label> + <textarea name="encrypted_message" id="encrypted_message" rows="10" class="form-control" style="font-family: SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;" readonly>{{ .Data.EncryptedMessage }}</textarea> + </div> + <div class="form-group"> + <label for="pgp_code" style="background-color: rgba(80, 80, 80, 0.8) !important; padding: 3px 5px;">{{ t "Your decrypted code" . }}</label> + <input name="pgp_code" id="pgp_code" value="{{ .Data.Code }}" type="text" class="form-control{{ if .Data.ErrorCode }} is-invalid{{ end }}" autofocus /> + {{ if .Data.ErrorCode }} + <div class="invalid-feedback">{{ .Data.ErrorCode }}</div> + {{ end }} + </div> + {{ end }} + <div class="form-group"> + <input type="submit" value="{{ t "Continue" . }}" class="btn btn-primary" /> + <a href="/settings/pgp" class="btn btn-secondary">{{ t "Cancel" . }}</a> + </div> + </div> + </div> + </fieldset> + </form> + {{ else if eq .Data.Step 3 }} + <form autocomplete="on" method="post"> + <input type="hidden" name="csrf" value="{{ .CSRF }}" /> + <input type="hidden" name="form_name" value="step3" /> + <fieldset> + <div class="row"> + <div class="center-block"> + </div> + </div> + <div class="row"> + <div class="col-sm-12 col-md-10 offset-md-1 "> + <div class="form-group"> + <input placeholder="{{ t "New password" . }}" name="newPassword" value="{{ .Data.NewPassword }}" class="form-control{{ if .Data.ErrorNewPassword }} is-invalid{{ end }}" type="password"{{ if .Data.ErrorNewPassword }} autofocus{{ end }} required /> + {{ if .Data.ErrorNewPassword }} + <div class="invalid-feedback">{{ .Data.ErrorNewPassword }}</div> + {{ end }} + </div> + <div class="form-group"> + <input placeholder="{{ t "Confirm new password" . }}" name="rePassword" value="{{ .Data.RePassword }}" class="form-control{{ if .Data.ErrorRePassword }} is-invalid{{ end }}" type="password"{{ if .Data.ErrorRePassword }} autofocus{{ end }} /> + {{ if .Data.ErrorRePassword }} + <div class="invalid-feedback">{{ .Data.ErrorRePassword }}</div> + {{ end }} + </div> + <div class="form-group"> + <input type="submit" value="{{ t "Update password" . }}" class="btn btn-primary" /> + </div> + </div> + </div> + </fieldset> + </form> + {{ end }} + </div> + </div> + </div> +</div> + +</body> +</html>{{ end }} +\ No newline at end of file diff --git a/pkg/web/public/views/pages/login.gohtml b/pkg/web/public/views/pages/login.gohtml @@ -113,7 +113,7 @@ </div> <div class="form-group"> <a href="/signup" style="background-color: rgba(80, 80, 80, 0.8) !important; padding: 3px 5px; color: #7abaff;">{{ t "Register" . }}</a> -<!-- <a href="/forgot-password">{{ t "I forgot my password" . }}</a>--> + <a href="/forgot-password" style="background-color: rgba(80, 80, 80, 0.8) !important; padding: 3px 5px; color: #7abaff;">{{ t "I forgot my password" . }}</a> </div> {{ if .Data.HomeUsersList }} <div class="form-group">