From 86daa70ccbefdc800a1576d665276ff163739ca6 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 22 Jun 2023 12:04:40 +0100 Subject: [PATCH] userpage: password resets click "forgot password" on login modal, enter a contact method address/username, submit and check for a link. Requires link reset to be enabled. --- api-userpage.go | 55 +++++++++++++++++++++++++++++++ email.go | 75 +++++++++++++++++++++++++----------------- html/admin.html | 3 ++ html/login-modal.html | 5 +++ html/user.html | 27 +++++++++++++++ lang/common/en-us.json | 3 +- lang/form/en-us.json | 6 +++- pwreset.go | 13 ++++++++ router.go | 1 + ts/typings/d.ts | 1 + ts/user.ts | 29 ++++++++++++++++ views.go | 3 ++ 12 files changed, 189 insertions(+), 32 deletions(-) diff --git a/api-userpage.go b/api-userpage.go index 93f856a..189b8aa 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -477,3 +477,58 @@ func (app *appContext) UnlinkMyMatrix(gc *gin.Context) { app.storage.DeleteMatrixKey(gc.GetString("jfId")) respondBool(200, true, gc) } + +// @Summary Generate & send a password reset link if the given email/contact method exists. Doesn't give you any info about it's success. +// @Produce json +// @Param address path string true "address/contact method associated w/ your account." +// @Success 204 {object} boolResponse +// @Failure 400 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Router /my/password/reset/{address} [post] +// @tags Users +func (app *appContext) ResetMyPassword(gc *gin.Context) { + address := gc.Param("address") + if address == "" { + app.debug.Println("Ignoring empty request for PWR") + respondBool(400, false, gc) + return + } + var pwr InternalPWR + var err error + + jfID := app.reverseUserSearch(address) + if jfID == "" { + app.debug.Printf("Ignoring PWR request: User not found") + respondBool(204, true, gc) + return + } + pwr, err = app.GenInternalReset(jfID) + if err != nil { + app.err.Printf("Failed to get user from Jellyfin: %v", err) + respondBool(500, false, gc) + return + } + if app.internalPWRs == nil { + app.internalPWRs = map[string]InternalPWR{} + } + app.internalPWRs[pwr.PIN] = pwr + // FIXME: Send to all contact methods + msg, err := app.email.constructReset( + PasswordReset{ + Pin: pwr.PIN, + Username: pwr.Username, + Expiry: pwr.Expiry, + Internal: true, + }, app, false, + ) + if err != nil { + app.err.Printf("Failed to construct password reset message for \"%s\": %v", pwr.Username, err) + respondBool(500, false, gc) + return + } else if err := app.sendByID(msg, jfID); err != nil { + app.err.Printf("Failed to send password reset message to \"%s\": %v", address, err) + } else { + app.info.Printf("Sent password reset message to \"%s\"", address) + } + respondBool(204, true, gc) +} diff --git a/email.go b/email.go index 777ed25..dda9428 100644 --- a/email.go +++ b/email.go @@ -522,18 +522,6 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite return email, nil } -// GenResetLink generates and returns a password reset link. -func (app *appContext) GenResetLink(pin string) (string, error) { - url := app.config.Section("password_resets").Key("url_base").String() - var pinLink string - if url == "" { - return pinLink, fmt.Errorf("disabled as no URL Base provided. Set in Settings > Password Resets.") - } - // Strip /invite from end of this URL, ik it's ugly. - pinLink = fmt.Sprintf("%s/reset?pin=%s", url, pin) - return pinLink, nil -} - func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} { d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern) message := app.config.Section("messages").Key("message").String() @@ -835,38 +823,37 @@ func (emailer *Emailer) send(email *Message, address ...string) error { return emailer.sender.Send(emailer.fromName, emailer.fromAddr, email, address...) } -func (app *appContext) sendByID(email *Message, ID ...string) error { +func (app *appContext) sendByID(email *Message, ID ...string) (err error) { for _, id := range ID { - var err error if tgChat, ok := app.storage.GetTelegramKey(id); ok && tgChat.Contact && telegramEnabled { err = app.telegram.Send(email, tgChat.ChatID) - if err != nil { - return err - } + // if err != nil { + // return err + // } } if dcChat, ok := app.storage.GetDiscordKey(id); ok && dcChat.Contact && discordEnabled { err = app.discord.Send(email, dcChat.ChannelID) - if err != nil { - return err - } + // if err != nil { + // return err + // } } if mxChat, ok := app.storage.GetMatrixKey(id); ok && mxChat.Contact && matrixEnabled { err = app.matrix.Send(email, mxChat) - if err != nil { - return err - } + // if err != nil { + // return err + // } } if address, ok := app.storage.GetEmailsKey(id); ok && address.Contact && emailEnabled { err = app.email.send(email, address.Addr) - if err != nil { - return err - } - } - if err != nil { - return err + // if err != nil { + // return err + // } } + // if err != nil { + // return err + // } } - return nil + return } func (app *appContext) getAddressOrName(jfID string) string { @@ -879,5 +866,33 @@ func (app *appContext) getAddressOrName(jfID string) string { if addr, ok := app.storage.GetEmailsKey(jfID); ok { return addr.Addr } + if mxChat, ok := app.storage.GetMatrixKey(jfID); ok && mxChat.Contact && matrixEnabled { + return mxChat.UserID + } + return "" +} + +func (app *appContext) reverseUserSearch(address string) string { + for id, email := range app.storage.GetEmails() { + if strings.ToLower(address) == strings.ToLower(email.Addr) { + return id + } + } + for id, dcUser := range app.storage.GetDiscord() { + if RenderDiscordUsername(dcUser) == strings.ToLower(address) { + return id + } + } + tgUsername := strings.TrimPrefix(address, "@") + for id, tgUser := range app.storage.GetTelegram() { + if tgUsername == tgUser.Username { + return id + } + } + for id, mxUser := range app.storage.GetMatrix() { + if address == mxUser.UserID { + return id + } + } return "" } diff --git a/html/admin.html b/html/admin.html index bd17f32..b062065 100644 --- a/html/admin.html +++ b/html/admin.html @@ -410,6 +410,9 @@ +
+ {{ .strings.myAccount }} +
diff --git a/html/login-modal.html b/html/login-modal.html index da49e20..2f9c3fb 100644 --- a/html/login-modal.html +++ b/html/login-modal.html @@ -14,6 +14,11 @@
diff --git a/html/user.html b/html/user.html index 4c62619..b709969 100644 --- a/html/user.html +++ b/html/user.html @@ -6,6 +6,7 @@ window.notificationsEnabled = {{ .notifications }}; window.ombiEnabled = {{ .ombiEnabled }}; window.langFile = JSON.parse({{ .language }}); + window.pwrEnabled = {{ .pwrEnabled }}; window.linkResetEnabled = {{ .linkResetEnabled }}; window.language = "{{ .langName }}"; window.telegramEnabled = {{ .telegramEnabled }}; @@ -43,6 +44,30 @@
+ {{ if .pwrEnabled }} + + {{ end }} {{ template "login-modal.html" . }} {{ template "account-linking.html" . }}
@@ -68,6 +93,8 @@ {{ .strings.logout }} + +
{{ .strings.admin }}
diff --git a/lang/common/en-us.json b/lang/common/en-us.json index 266216f..43539db 100644 --- a/lang/common/en-us.json +++ b/lang/common/en-us.json @@ -38,7 +38,8 @@ "expiry": "Expiry", "add": "Add", "edit": "Edit", - "delete": "Delete" + "delete": "Delete", + "myAccount": "My Account" }, "notifications": { "errorLoginBlank": "The username and/or password were left blank.", diff --git a/lang/form/en-us.json b/lang/form/en-us.json index 946af1f..5b30b33 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -27,7 +27,11 @@ "customMessagePlaceholderHeader": "Customize this card", "customMessagePlaceholderContent": "Click the user page edit button in settings to customize this card, or show one on the login screen, and don't worry, the user can't see this.", "userPageSuccessMessage": "You can see and change details about your account later on the {myAccount} page.", - "myAccount": "My Account" + "resetPassword": "Reset Password", + "resetPasswordThroughJellyfin": "To reset your password, visit {jfLink} and press the \"Forgot Password\" button.", + "resetPasswordThroughLink": "To reset your password, enter your email address or a linked contact method username, and submit. A link will be sent to reset your password.", + "resetSent": "Reset Sent.", + "resetSentDescription": "If an account with the given contact method exists, a password reset link has been sent to all contact methods available. The code will expire in 30 minutes." }, "notifications": { "errorUserExists": "User already exists.", diff --git a/pwreset.go b/pwreset.go index 22e9a74..be3d3fb 100644 --- a/pwreset.go +++ b/pwreset.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "os" "strings" "time" @@ -25,6 +26,18 @@ func (app *appContext) GenInternalReset(userID string) (InternalPWR, error) { return pwr, nil } +// GenResetLink generates and returns a password reset link. +func (app *appContext) GenResetLink(pin string) (string, error) { + url := app.config.Section("password_resets").Key("url_base").String() + var pinLink string + if url == "" { + return pinLink, fmt.Errorf("disabled as no URL Base provided. Set in Settings > Password Resets.") + } + // Strip /invite from end of this URL, ik it's ugly. + pinLink = fmt.Sprintf("%s/reset?pin=%s", url, pin) + return pinLink, nil +} + func (app *appContext) StartPWR() { app.info.Println("Starting password reset daemon") path := app.config.Section("password_resets").Key("watch_directory").String() diff --git a/router.go b/router.go index f0fd890..ef6254b 100644 --- a/router.go +++ b/router.go @@ -148,6 +148,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.GET(p+"/my/token/login", app.getUserTokenLogin) router.GET(p+"/my/token/refresh", app.getUserTokenRefresh) router.GET(p+"/my/confirm/:jwt", app.ConfirmMyAction) + router.POST(p+"/my/password/reset/:address", app.ResetMyPassword) } } if *SWAGGER { diff --git a/ts/typings/d.ts b/ts/typings/d.ts index 4c174da..cae4f54 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -110,6 +110,7 @@ declare interface Modals { discord: Modal; matrix: Modal; sendPWR?: Modal; + pwr?: Modal; logs: Modal; email?: Modal; } diff --git a/ts/user.ts b/ts/user.ts index ed71747..03e8f28 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -16,6 +16,7 @@ interface userWindow extends Window { discordInviteLink: boolean; matrixUserID: string; discordSendPINMessage: string; + pwrEnabled: string; } declare var window: userWindow; @@ -44,10 +45,38 @@ window.modals = {} as Modals; if (window.matrixEnabled) { window.modals.matrix = new Modal(document.getElementById("modal-matrix"), false); } + if (window.pwrEnabled) { + window.modals.pwr = new Modal(document.getElementById("modal-pwr"), false); + window.modals.pwr.onclose = () => { + window.modals.login.show(); + }; + const resetButton = document.getElementById("modal-login-pwr"); + resetButton.onclick = () => { + window.modals.login.close(); + window.modals.pwr.show(); + } + } })(); window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5); +if (window.pwrEnabled && window.linkResetEnabled) { + const submitButton = document.getElementById("pwr-submit"); + const input = document.getElementById("pwr-address") as HTMLInputElement; + submitButton.onclick = () => _post("/my/password/reset/" + input.value, null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + if (req.status != 204) { + window.notifications.customError("unkownError", window.lang.notif("errorUnknown"));; + window.modals.pwr.close(); + return; + } + window.modals.pwr.modal.querySelector(".heading").textContent = window.lang.strings("resetSent"); + window.modals.pwr.modal.querySelector(".content").textContent = window.lang.strings("resetSentDescription"); + submitButton.classList.add("unfocused"); + input.classList.add("unfocused"); + }); +} + const grid = document.querySelector(".grid"); var rootCard = document.getElementById("card-user"); var contactCard = document.getElementById("card-contact"); diff --git a/views.go b/views.go index 62f2ed1..43f6fb1 100644 --- a/views.go +++ b/views.go @@ -157,6 +157,7 @@ func (app *appContext) AdminPage(gc *gin.Context) { "jellyfinLogin": app.jellyfinLogin, "jfAdminOnly": jfAdminOnly, "jfAllowAll": jfAllowAll, + "userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false), }) } @@ -177,6 +178,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) { "discordEnabled": discordEnabled, "matrixEnabled": matrixEnabled, "ombiEnabled": ombiEnabled, + "pwrEnabled": app.config.Section("password_resets").Key("enabled").MustBool(false), "linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false), "notifications": notificationsEnabled, "username": !app.config.Section("email").Key("no_username").MustBool(false), @@ -184,6 +186,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) { "validationStrings": app.storage.lang.User[lang].ValidationStrings, "language": app.storage.lang.User[lang].JSON, "langName": lang, + "jfLink": app.config.Section("ui").Key("redirect_url").String(), } if telegramEnabled { data["telegramUsername"] = app.telegram.username