views.go 16 KB

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