commit 56bac4cdc444f6e111a37c5e7254a2fa3a8023cf
parent 68baa69815c2e6bcfdc19abdc74f9df2f3850f6f
Author: n0tr1v <n0tr1v@protonmail.com>
Date: Sat, 3 Dec 2022 17:11:41 -0500
password recovery form
Diffstat:
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">