views.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. package login
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "fmt"
  6. "image/png"
  7. "net/http"
  8. "net/url"
  9. "github.com/pquerna/otp"
  10. "github.com/qor5/web"
  11. "github.com/qor5/x/i18n"
  12. . "github.com/theplant/htmlgo"
  13. "golang.org/x/text/language"
  14. "golang.org/x/text/language/display"
  15. )
  16. func defaultLoginPage(vh *ViewHelper) web.PageFunc {
  17. return func(ctx *web.EventContext) (r web.PageResponse, err error) {
  18. // i18n start
  19. msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
  20. i18nBuilder := vh.I18n()
  21. var languagesHTML []HTMLComponent
  22. languages := i18nBuilder.GetSupportLanguages()
  23. if len(languages) > 1 {
  24. qn := i18nBuilder.GetQueryName()
  25. lang := ctx.R.FormValue(qn)
  26. if lang == "" {
  27. lang = i18nBuilder.GetCurrentLangFromCookie(ctx.R)
  28. }
  29. accept := ctx.R.Header.Get("Accept-Language")
  30. _, mi := language.MatchStrings(language.NewMatcher(languages), lang, accept)
  31. for i, l := range languages {
  32. u, _ := url.Parse(ctx.R.RequestURI)
  33. qs := u.Query()
  34. qs.Set(qn, l.String())
  35. u.RawQuery = qs.Encode()
  36. elem := Option(display.Self.Name(l)).
  37. Value(u.String())
  38. if i == mi {
  39. elem.Attr("selected", "selected")
  40. }
  41. languagesHTML = append(languagesHTML, elem)
  42. }
  43. }
  44. // i18n end
  45. var oauthHTML HTMLComponent
  46. if vh.OAuthEnabled() {
  47. ul := Div().Class("flex flex-col justify-center mt-8 text-center")
  48. for _, provider := range vh.OAuthProviders() {
  49. ul.AppendChildren(
  50. A().
  51. Href(fmt.Sprintf("%s?provider=%s", vh.OAuthBeginURL(), provider.Key)).
  52. Class("px-6 py-3 mt-4 font-semibold text-gray-900 bg-white border-2 border-gray-500 rounded-md shadow outline-none hover:bg-yellow-50 hover:border-yellow-400 focus:outline-none").
  53. Children(
  54. provider.Logo,
  55. Text(provider.Text),
  56. ),
  57. )
  58. }
  59. oauthHTML = Div(
  60. ul,
  61. )
  62. }
  63. wIn := vh.GetWrongLoginInputFlash(ctx.W, ctx.R)
  64. isRecaptchaEnabled := vh.RecaptchaEnabled()
  65. var userPassHTML HTMLComponent
  66. if vh.UserPassEnabled() {
  67. userPassHTML = Div(
  68. Form(
  69. Div(
  70. Label(msgr.AccountLabel).Class(DefaultViewCommon.LabelClass).For("account"),
  71. Input("account").Placeholder(msgr.AccountPlaceholder).Class(DefaultViewCommon.InputClass).
  72. Value(wIn.Account),
  73. ),
  74. Div(
  75. Label(msgr.PasswordLabel).Class(DefaultViewCommon.LabelClass).For("password"),
  76. DefaultViewCommon.PasswordInputWithRevealFunction("password", msgr.PasswordPlaceholder, "password", wIn.Password),
  77. ).Class("mt-6"),
  78. If(isRecaptchaEnabled,
  79. Div(
  80. // recaptcha response token
  81. Input("token").Id("token"),
  82. ).Class("hidden"),
  83. ),
  84. Div(
  85. Button(msgr.SignInBtn).Class(DefaultViewCommon.ButtonClass).
  86. ClassIf("g-recaptcha", isRecaptchaEnabled).AttrIf("data-sitekey", vh.RecaptchaSiteKey(), isRecaptchaEnabled).AttrIf("data-callback", "onSubmit", isRecaptchaEnabled),
  87. ).Class("mt-6"),
  88. ).Id("login-form").Method(http.MethodPost).Action(vh.PasswordLoginURL()),
  89. If(!vh.NoForgetPasswordLink(),
  90. Div(
  91. A(Text(msgr.ForgetPasswordLink)).Href(vh.ForgetPasswordPageURL()).
  92. Class("text-gray-500"),
  93. ).Class("text-right mt-2"),
  94. ),
  95. )
  96. }
  97. r.PageTitle = msgr.LoginPageTitle
  98. var bodyForm HTMLComponent
  99. bodyForm = Div(
  100. userPassHTML,
  101. oauthHTML,
  102. If(len(languagesHTML) > 0,
  103. Select(
  104. languagesHTML...,
  105. ).Class("mt-12 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500").
  106. Attr("onChange", "window.location.href=this.value"),
  107. ),
  108. ).Class(DefaultViewCommon.WrapperClass)
  109. r.Body = Div(
  110. Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
  111. If(isRecaptchaEnabled,
  112. Style(`.grecaptcha-badge { visibility: hidden; }`),
  113. Script("").Src("https://www.google.com/recaptcha/api.js"),
  114. Script(`
  115. function onSubmit(token) {
  116. document.getElementById("token").value = token;
  117. document.getElementById("login-form").submit();
  118. }
  119. `)),
  120. DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
  121. bodyForm,
  122. )
  123. return
  124. }
  125. }
  126. func defaultForgetPasswordPage(vh *ViewHelper) web.PageFunc {
  127. return func(ctx *web.EventContext) (r web.PageResponse, err error) {
  128. msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
  129. wIn := vh.GetWrongForgetPasswordInputFlash(ctx.W, ctx.R)
  130. secondsToResend := vh.GetSecondsToRedoFlash(ctx.W, ctx.R)
  131. activeBtnText := msgr.SendResetPasswordEmailBtn
  132. activeBtnClass := DefaultViewCommon.ButtonClass
  133. inactiveBtnText := msgr.ResendResetPasswordEmailBtn
  134. inactiveBtnClass := "w-full px-6 py-3 tracking-wide text-white transition-colors duration-200 transform bg-gray-500 rounded-md"
  135. inactiveBtnTextWithInitSeconds := fmt.Sprintf("%s (%d)", inactiveBtnText, secondsToResend)
  136. doTOTP := ctx.R.URL.Query().Get("totp") == "1"
  137. actionURL := vh.SendResetPasswordLinkURL()
  138. if doTOTP {
  139. actionURL = MustSetQuery(actionURL, "totp", "1")
  140. }
  141. isRecaptchaEnabled := vh.RecaptchaEnabled()
  142. r.PageTitle = msgr.ForgetPasswordPageTitle
  143. r.Body = Div(
  144. Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
  145. If(isRecaptchaEnabled,
  146. Style(`.grecaptcha-badge { visibility: hidden; }`),
  147. Script("").Src("https://www.google.com/recaptcha/api.js"),
  148. Script(`
  149. function onSubmit(token) {
  150. document.getElementById("token").value = token;
  151. document.getElementById("forget-form").submit();
  152. }
  153. `)),
  154. DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
  155. If(secondsToResend > 0,
  156. DefaultViewCommon.WarnNotice(msgr.SendEmailTooFrequentlyNotice),
  157. ),
  158. Div(
  159. H1(msgr.ForgotMyPasswordTitle).Class(DefaultViewCommon.TitleClass),
  160. Form(
  161. Div(
  162. Label(msgr.ForgetPasswordEmailLabel).Class(DefaultViewCommon.LabelClass).For("account"),
  163. Input("account").Placeholder(msgr.ForgetPasswordEmailPlaceholder).Class(DefaultViewCommon.InputClass).Value(wIn.Account),
  164. ),
  165. If(doTOTP,
  166. Div(
  167. Label(msgr.TOTPValidateCodeLabel).Class(DefaultViewCommon.LabelClass).For("otp"),
  168. Input("otp").Placeholder(msgr.TOTPValidateCodePlaceholder).
  169. Class(DefaultViewCommon.InputClass).
  170. Value(wIn.TOTP),
  171. ).Class("mt-6"),
  172. ),
  173. If(isRecaptchaEnabled,
  174. Div(
  175. // recaptcha response token
  176. Input("token").Id("token"),
  177. ).Class("hidden"),
  178. ),
  179. Div(
  180. If(secondsToResend > 0,
  181. Button(inactiveBtnTextWithInitSeconds).Id("submitBtn").Class(inactiveBtnClass).Disabled(true),
  182. ).Else(
  183. Button(activeBtnText).Class(activeBtnClass).
  184. ClassIf("g-recaptcha", isRecaptchaEnabled).AttrIf("data-sitekey", vh.RecaptchaSiteKey(), isRecaptchaEnabled).AttrIf("data-callback", "onSubmit", isRecaptchaEnabled),
  185. ),
  186. ).Class("mt-6"),
  187. ).Id("forget-form").Method(http.MethodPost).Action(actionURL),
  188. ).Class(DefaultViewCommon.WrapperClass),
  189. )
  190. if secondsToResend > 0 {
  191. ctx.Injector.TailHTML(fmt.Sprintf(`
  192. <script>
  193. (function(){
  194. var secondsToResend = %d;
  195. var btnText = "%s";
  196. var submitBtn = document.getElementById("submitBtn");
  197. var interv = setInterval(function(){
  198. secondsToResend--;
  199. if (secondsToResend === 0) {
  200. clearInterval(interv);
  201. submitBtn.innerText = btnText;
  202. submitBtn.className = "%s";
  203. submitBtn.disabled = false;
  204. return;
  205. }
  206. submitBtn.innerText = btnText + " (" + secondsToResend + ")" ;
  207. }, 1000);
  208. })();
  209. </script>
  210. `, secondsToResend, inactiveBtnText, activeBtnClass))
  211. }
  212. return
  213. }
  214. }
  215. func defaultResetPasswordLinkSentPage(vh *ViewHelper) web.PageFunc {
  216. return func(ctx *web.EventContext) (r web.PageResponse, err error) {
  217. msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
  218. a := ctx.R.URL.Query().Get("a")
  219. r.PageTitle = msgr.ResetPasswordLinkSentPageTitle
  220. r.Body = Div(
  221. Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
  222. DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
  223. Div(
  224. H1(fmt.Sprintf("%s %s.", msgr.ResetPasswordLinkWasSentTo, a)).Class("leading-tight text-2xl mt-0 mb-4"),
  225. H2(msgr.ResetPasswordLinkSentPrompt).Class("leading-tight text-1xl mt-0"),
  226. ).Class(DefaultViewCommon.WrapperClass),
  227. )
  228. return
  229. }
  230. }
  231. func defaultResetPasswordPage(vh *ViewHelper) web.PageFunc {
  232. return func(ctx *web.EventContext) (r web.PageResponse, err error) {
  233. msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
  234. wIn := vh.GetWrongResetPasswordInputFlash(ctx.W, ctx.R)
  235. doTOTP := ctx.R.URL.Query().Get("totp") == "1"
  236. actionURL := vh.ResetPasswordURL()
  237. if doTOTP {
  238. actionURL = MustSetQuery(actionURL, "totp", "1")
  239. }
  240. var user interface{}
  241. r.PageTitle = msgr.ResetPasswordPageTitle
  242. query := ctx.R.URL.Query()
  243. id := query.Get("id")
  244. if id == "" {
  245. r.Body = Div(Text("user not found"))
  246. return r, nil
  247. } else {
  248. user, err = vh.FindUserByID(id)
  249. if err != nil {
  250. if err == ErrUserNotFound {
  251. r.Body = Div(Text("user not found"))
  252. return r, nil
  253. }
  254. panic(err)
  255. }
  256. }
  257. token := query.Get("token")
  258. if token == "" {
  259. r.Body = Div(Text("invalid token"))
  260. return r, nil
  261. } else {
  262. storedToken, _, expired := user.(UserPasser).GetResetPasswordToken()
  263. if expired {
  264. r.Body = Div(Text("token expired"))
  265. return r, nil
  266. }
  267. if token != storedToken {
  268. r.Body = Div(Text("invalid token"))
  269. return r, nil
  270. }
  271. }
  272. r.Body = Div(
  273. Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
  274. Script("").Src(ZxcvbnJSURL),
  275. DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
  276. Div(
  277. H1(msgr.ResetYourPasswordTitle).Class(DefaultViewCommon.TitleClass),
  278. Form(
  279. Input("user_id").Type("hidden").Value(id),
  280. Input("token").Type("hidden").Value(token),
  281. Div(
  282. Label(msgr.ResetPasswordLabel).Class(DefaultViewCommon.LabelClass).For("password"),
  283. DefaultViewCommon.PasswordInputWithRevealFunction("password", msgr.ResetPasswordPlaceholder, "password", wIn.Password),
  284. DefaultViewCommon.PasswordStrengthMeter("password"),
  285. ),
  286. Div(
  287. Label(msgr.ResetPasswordConfirmLabel).Class(DefaultViewCommon.LabelClass).For("confirm_password"),
  288. DefaultViewCommon.PasswordInputWithRevealFunction("confirm_password", msgr.ResetPasswordConfirmPlaceholder, "confirm_password", wIn.ConfirmPassword),
  289. ).Class("mt-6"),
  290. If(doTOTP,
  291. Div(
  292. Label(msgr.TOTPValidateCodeLabel).Class(DefaultViewCommon.LabelClass).For("otp"),
  293. Input("otp").Placeholder(msgr.TOTPValidateCodePlaceholder).
  294. Class(DefaultViewCommon.InputClass).
  295. Value(wIn.TOTP),
  296. ).Class("mt-6"),
  297. ),
  298. Div(
  299. Button(msgr.Confirm).Class(DefaultViewCommon.ButtonClass),
  300. ).Class("mt-6"),
  301. ).Method(http.MethodPost).Action(actionURL),
  302. ).Class(DefaultViewCommon.WrapperClass),
  303. )
  304. return
  305. }
  306. }
  307. func defaultChangePasswordPage(vh *ViewHelper) web.PageFunc {
  308. return func(ctx *web.EventContext) (r web.PageResponse, err error) {
  309. msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
  310. wIn := vh.GetWrongChangePasswordInputFlash(ctx.W, ctx.R)
  311. r.PageTitle = msgr.ChangePasswordPageTitle
  312. r.Body = Div(
  313. Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
  314. Script("").Src(ZxcvbnJSURL),
  315. DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
  316. Div(
  317. H1(msgr.ChangePasswordTitle).Class(DefaultViewCommon.TitleClass),
  318. Form(
  319. Div(
  320. Label(msgr.ChangePasswordOldLabel).Class(DefaultViewCommon.LabelClass).For("old_password"),
  321. DefaultViewCommon.PasswordInputWithRevealFunction("old_password", msgr.ChangePasswordOldPlaceholder, "old_password", wIn.OldPassword),
  322. ),
  323. Div(
  324. Label(msgr.ChangePasswordNewLabel).Class(DefaultViewCommon.LabelClass).For("password"),
  325. DefaultViewCommon.PasswordInputWithRevealFunction("password", msgr.ChangePasswordNewPlaceholder, "password", wIn.NewPassword),
  326. DefaultViewCommon.PasswordStrengthMeter("password"),
  327. ).Class("mt-6"),
  328. Div(
  329. Label(msgr.ChangePasswordNewConfirmLabel).Class(DefaultViewCommon.LabelClass).For("confirm_password"),
  330. DefaultViewCommon.PasswordInputWithRevealFunction("confirm_password", msgr.ChangePasswordNewConfirmPlaceholder, "confirm_password", wIn.ConfirmPassword),
  331. ).Class("mt-6"),
  332. If(vh.TOTPEnabled(),
  333. Div(
  334. Label(msgr.TOTPValidateCodeLabel).Class(DefaultViewCommon.LabelClass).For("otp"),
  335. Input("otp").Placeholder(msgr.TOTPValidateCodePlaceholder).
  336. Class(DefaultViewCommon.InputClass).
  337. Value(wIn.TOTP),
  338. ).Class("mt-6"),
  339. ),
  340. Div(
  341. Button(msgr.Confirm).Class(DefaultViewCommon.ButtonClass),
  342. ).Class("mt-6"),
  343. ).Method(http.MethodPost).Action(vh.ChangePasswordURL()),
  344. ).Class(DefaultViewCommon.WrapperClass),
  345. )
  346. return
  347. }
  348. }
  349. func defaultTOTPSetupPage(vh *ViewHelper) web.PageFunc {
  350. return func(ctx *web.EventContext) (r web.PageResponse, err error) {
  351. msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
  352. user := GetCurrentUser(ctx.R)
  353. u := user.(UserPasser)
  354. var QRCode bytes.Buffer
  355. // Generate key from TOTPSecret
  356. var key *otp.Key
  357. totpSecret := u.GetTOTPSecret()
  358. if len(totpSecret) == 0 {
  359. r.Body = DefaultViewCommon.ErrorBody("need setup totp")
  360. return
  361. }
  362. key, err = otp.NewKeyFromURL(
  363. fmt.Sprintf("otpauth://totp/%s:%s?issuer=%s&secret=%s",
  364. url.PathEscape(vh.TOTPIssuer()),
  365. url.PathEscape(u.GetAccountName()),
  366. url.QueryEscape(vh.TOTPIssuer()),
  367. url.QueryEscape(totpSecret),
  368. ),
  369. )
  370. img, err := key.Image(200, 200)
  371. if err != nil {
  372. r.Body = DefaultViewCommon.ErrorBody(err.Error())
  373. return
  374. }
  375. err = png.Encode(&QRCode, img)
  376. if err != nil {
  377. r.Body = DefaultViewCommon.ErrorBody(err.Error())
  378. return
  379. }
  380. r.PageTitle = msgr.TOTPSetupPageTitle
  381. r.Body = Div(
  382. Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
  383. DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
  384. Div(
  385. Div(
  386. H1(msgr.TOTPSetupTitle).
  387. Class(DefaultViewCommon.TitleClass),
  388. Label(msgr.TOTPSetupScanPrompt),
  389. ),
  390. Div(
  391. Img(fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(QRCode.Bytes()))),
  392. ).Class("my-2 flex items-center justify-center"),
  393. Div(
  394. Label(msgr.TOTPSetupSecretPrompt),
  395. ),
  396. Div(Label(u.GetTOTPSecret()).Class("text-sm font-bold")).Class("my-4"),
  397. Form(
  398. Label(msgr.TOTPSetupEnterCodePrompt),
  399. Input("otp").Placeholder(msgr.TOTPSetupCodePlaceholder).
  400. Class(DefaultViewCommon.InputClass).
  401. Class("mt-6"),
  402. Div(
  403. Button(msgr.Verify).Class(DefaultViewCommon.ButtonClass),
  404. ).Class("mt-6"),
  405. ).Method(http.MethodPost).Action(vh.ValidateTOTPURL()),
  406. ).Class(DefaultViewCommon.WrapperClass).Class("text-center"),
  407. )
  408. return
  409. }
  410. }
  411. func defaultTOTPValidatePage(vh *ViewHelper) web.PageFunc {
  412. return func(ctx *web.EventContext) (r web.PageResponse, err error) {
  413. msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
  414. r.PageTitle = msgr.TOTPValidatePageTitle
  415. r.Body = Div(
  416. Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
  417. DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
  418. Div(
  419. Div(
  420. H1(msgr.TOTPValidateTitle).
  421. Class(DefaultViewCommon.TitleClass),
  422. Label(msgr.TOTPValidateEnterCodePrompt),
  423. ),
  424. Form(
  425. Input("otp").Placeholder(msgr.TOTPValidateCodePlaceholder).
  426. Class(DefaultViewCommon.InputClass).
  427. Class("mt-6").
  428. Attr("autofocus", true),
  429. Div(
  430. Button(msgr.Verify).Class(DefaultViewCommon.ButtonClass),
  431. ).Class("mt-6"),
  432. ).Method(http.MethodPost).Action(vh.ValidateTOTPURL()),
  433. ).Class(DefaultViewCommon.WrapperClass).Class("text-center"),
  434. )
  435. return
  436. }
  437. }